VS Code 拡張機能 × Wasm × Language Server

近年、VS Code (Visual Studio Code) における WebAssembly（Wasm）のサポートが進化しています。
特に注目すべきは、Language Server を WebAssembly（Wasm）で実装することで、Node.js に依存せず VS Code 上で動作させられるようになった点です。

この記事では、Rust 製 Language Server を Wasm 化し、VS Code 拡張として動かす方法を、Microsoft 公式サンプルをもとに解説します。

# Language Server と Wasm の基礎知識

VS Code の言語機能は Language Server Protocol（LSP）を通じて実装されており、サーバー（バックエンド）とクライアント（VS Code 拡張）が分離されています。

Wasm によって、この Language Server をブラウザ実行環境や非 Node.js 環境でも動作させられるようになります。

# Web 上でも動く拡張機能

Wasm による最大の利点は、Node.js 依存を排除できるため、`vscode.dev` や GitHub Codespaces などの Web 環境でも Language Server を動作させられる点にあります。

# サンプルを実行してみる

公式サンプル [`wasm-language-server`](https://github.com/microsoft/vscode-extension-samples/tree/main/wasm-language-server) を使って実際の機能を体感してみましょう。


このサンプルは、Rust 製の簡易 Language Server を Wasm にビルドし、VS Code 拡張として動作させる例です。

このサンプルには2つの機能が実装されています。
1つはワークスペース内のファイル数を数える機能。
もう一つはプレーンテキストに「定義へ移動」コマンドを追加し、ファイルの先頭へ移動させる機能です。

![ファイル数カウント](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221488/84c73573-c8ed-45b9-a229-67051cf1c514.png)

![定義ジャンプ機能](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221488/b1fee638-b6a8-469f-9493-e3ebc6db20f1.png)
## サンプルの実行準備

以下を Web ページからインストールしてください。

- [VS Code](https://code.visualstudio.com/download)
- [Node.js](https://nodejs.org/ja/download) >= v22.14.0
- [Rust compiler toolchain](https://www.rust-lang.org/tools/install)
- [wasm-tools](https://github.com/bytecodealliance/wasm-tools)
- [wit-bindgen](https://github.com/bytecodealliance/wit-bindgen)
- [WASI based WebAssembly Execution Engine](https://marketplace.visualstudio.com/items/?itemName=ms-vscode.wasm-wasi-core)

<details>
<summary>動作確認済み前提バージョン</summary>

- VS Code >= 1.88.0  
- Node.js >= v22.14.0  
- Rust >= 1.86.0  
- wasm-tools >= v1.200  
- wit-bindgen  
- WASI based WebAssembly Execution Engine (VS Code拡張)

</details>

wit-bindgen, wasm32-unknown-unknown は以下でインストールします。

```powershell
rustup target add wasm32-wasip2
rustup target add wasm32-wasip1-threads
rustup target add wasm32-unknown-unknown
```

次にサンプル集をクローンし、開きましょう。

```powershell
# クローン
git clone https://github.com/microsoft/vscode-extension-samples.git
# VS Code でサンプルを開く
code vscode-extension-samples\wasm-language-server
```

## サンプルをビルド・実行

F5 キーで拡張機能をビルドし、インストールした VS Code を起動できます。

起動した VS Code で以下のようなテキストファイルを使ってサンプルの機能を2つ試してみましょう。

```hello.txt
Hello World!

Samples
```

## サンプルを実行: 定義ジャンプ機能

hello.txt の文字の上で右クリック、「定義へ移動」を選択しましょう。
ファイルの先頭まで移動します。


![定義ジャンプ機能](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221488/b1fee638-b6a8-469f-9493-e3ebc6db20f1.png)

## サンプルを実行: ファイルカウント機能

次に Ctrl+Shift+P から「Samples: Count Files」コマンドを実行してみましょう。
現在のワークスペース内にあるファイル数をログで表示してくれます。

![ファイル数カウント](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221488/84c73573-c8ed-45b9-a229-67051cf1c514.png)

# サンプルを読んでみる

## フォルダ構造

フォルダ構造は以下の通りです。
通常の TypeScript を利用する VS Code Language Server と似ていますが、サーバー側の言語が Rust になっています。

今回は以下の2ファイルに注目して紹介します。

- `client/src/extension.ts` — 通常通りの拡張機能エントリポイント。
- `server/src/main.rs` — Language Server の Rust 実装。

```txt
wasm-language-server/
├── .vscode/
│   └── launch.json
├── bin/
│   └── esbuild.js
├── client/
│   ├── bin/
│   │   └── esbuild.js
│   ├── node_modules
│   ├── src/
│   │   └── extension.ts
│   ├── package.json
│   └── tsconfig.json
├── server/
│   ├── bin/
│   │   └── send.js
│   ├── src/
│   │   └── ★main.rs
│   ├── Cargo.lock
│   ├── Cargo.toml
│   └── package.json
├── testbed/workspace/
│   └── lorem.txt
├── .gitignore
├── eslint.config.mjs
├── package.json
└── README.md
```

## 定義ジャンプ機能

### 定義ジャンプ機能: サーバーサイド

```rs:server\src\main.rs
match cast::<GotoDefinition>(req) {
    Ok((id, params)) => {
        // リクエストから対象のファイル URI を取得
        let uri = params.text_document_position_params.text_document.uri;

        // デバッグ用にリクエスト ID と URI を出力
        eprintln!("Received gotoDefinition request #{} {}", id, uri.to_string());

        // 定義ジャンプ先の位置を指定（今回はファイルの先頭を指定）
        let loc = Location::new(
            uri,
            lsp_types::Range::new(lsp_types::Position::new(0, 0), lsp_types::Position::new(0, 0))
        );

        // ジャンプ先の位置をレスポンス用の配列に追加
        let mut vec = Vec::new();
        vec.push(loc);

        // レスポンスを JSON にシリアライズ
        let result = Some(GotoDefinitionResponse::Array(vec));
        let result = serde_json::to_value(&result).unwrap();

        // クライアントにレスポンスを送信
        let resp = Response { id, result: Some(result), error: None };
        connection.sender.send(Message::Response(resp))?;

        // 次のリクエスト処理に進む
        continue;
    }
    // JSON パースエラーの場合はパニック
    Err(err @ ExtractError::JsonError { .. }) => panic!("{err:?}"),
    // メソッドが一致しない場合はリクエストをそのまま返す
    Err(ExtractError::MethodMismatch(req)) => req,
};
```

### 定義ジャンプ機能: クライアントサイド

```ts:client\src\extension.ts
import {
  createStdioOptions,
  createUriConverters,
  startServer
} from '@vscode/wasm-wasi-lsp';

export async function activate(context: ExtensionContext) {
  const wasm: Wasm = await Wasm.load();
  // 「出力」タブにエラーを表示する時のチャンネル名  
  const channel = window.createOutputChannel('LSP WASM Server');
  // WebAssembly language server を実行するサーバー設定
  const serverOptions: ServerOptions = async () => {
    const options: ProcessOptions = {
      stdio: createStdioOptions(),
      // WASM/WASI のファイルシステムのマッピング方法
      mountPoints: [
        // ワークスペースフォルダを `/workspace` としてマッピングする
        // マルチルートワークスペースの場合は、各フォルダを `/workspaces/folder-name` としてマッピングする
        { kind: 'workspaceFolder' },
      ]
    };

    // WebAssembly バイナリコードのパス
    const filename = Uri.joinPath(
      context.extensionUri,
      'server',
      'target',
      'wasm32-wasip1-threads',
      'release',
      'server.wasm'
    );
    // WebAssembly ファイルを読み込み
    const bits = await workspace.fs.readFile(filename);
    // WebAssembly バイナリコードを WebAssembly.Module にコンパイル
    const module = await WebAssembly.compile(bits);

    //  LSP サーバーで実行する wasm worker の作成
    const process = await wasm.createProcess(
      'lsp-server',
      module,
      { initial: 160, maximum: 160, shared: true },
      options
    );

    // output チャンネルに出力する stderr の設定
    const decoder = new TextDecoder('utf-8');
    process.stderr!.onData(data => {
      channel.append(decoder.decode(data));
    });

    return startServer(process);
  };

  // クライアントのオプション
  const clientOptions: LanguageClientOptions = {
    // プレーンテキスト（拡張子が.txtのファイル）に適用
    documentSelector: [{ language: 'plaintext' }],
    outputChannel: channel,
    uriConverters: createUriConverters()
  };

  // クライアントの立ち上げ  
  let client = new LanguageClient('lspClient', 'LSP Client', serverOptions, clientOptions);
  await client.start();
}
```

## ファイルカウント機能

### ファイルカウント機能: サーバーサイド

```rs:server\src\main.rs
#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CountFilesParams {
    pub folder: Url,
}

// ファイルカウントリクエストの定義
pub enum CountFilesRequest {}
impl Request for CountFilesRequest {
    type Params = CountFilesParams; // リクエストのパラメータ型
    type Result = u32;             // レスポンスの結果型
    const METHOD: &'static str = "wasm-language-server/countFilesInDirectory"; // メソッド名
}


// <省略>

// メッセージの受信ループ
for msg in &connection.receiver {
    match msg {
    // <省略>

  // ファイルカウントリクエストの処理
  match cast::<CountFilesRequest>(req) {
      Ok((id, params)) => {
          // リクエスト ID とフォルダの URL をデバッグ出力
          eprintln!("Received countFiles request #{} {}", id, params.folder);

          // 指定されたフォルダ内のファイル数をカウント
          let result = count_files_in_directory(&params.folder.path());

          // カウント結果を JSON にシリアライズ
          let json = serde_json::to_value(&result).unwrap();

          // レスポンスを作成してクライアントに送信
          let resp = Response { id, result: Some(json), error: None };
          connection.sender.send(Message::Response(resp))?;

          // 次のリクエスト処理に進む
          continue;
      }
      // JSON パースエラーの場合はパニック
      Err(err @ ExtractError::JsonError { .. }) => panic!("{err:?}"),
      // メソッドが一致しない場合はリクエストをそのまま返す
      Err(ExtractError::MethodMismatch(req)) => req,
  }
 }
 //...
}

// 指定されたディレクトリ内のファイル数をカウントする関数
fn count_files_in_directory(path: &str) -> usize {
    WalkDir::new(path) // 指定されたパスを再帰的に探索
        .into_iter()
        .filter_map(Result::ok) // エラーを無視して有効なエントリのみ取得
        .filter(|entry| entry.file_type().is_file()) // ファイルのみをフィルタリング
        .count() // ファイル数をカウント
}
```

### ファイルカウント機能: クライアントサイド

`client.sendRequest`

```ts:client\src\extension.ts
// 現在開いているフォルダのURIを取得
const folder = workspace.workspaceFolders![0].uri;
// ファイルをカウントする機能を呼び出し、結果を得る
const result = await client.sendRequest(CountFilesRequest, {
  folder: client.code2ProtocolConverter.asUri(folder)
});
// 結果を表示するウィンドウを起動
window.showInformationMessage(`The workspace contains ${result} files.`);
```

# おわりに：Wasm × LSP の未来

今回のように Wasm を使うことで、VS Code 拡張のクロスプラットフォーム化がさらに進みます。
Rust, C/C++, .NET, Swift など自分の好きな言語で Language Server を構築できる未来も見えてきました。

今後は、より実用的な LSP の Wasm 化や、既存プロジェクトへの導入事例も増えていくと期待されます。

ぜひ、今回のサンプルを土台に、自作言語や既存ツールの Language Server 化にチャレンジしてみてください。

# 参考文献

- [公式ブログ](https://code.visualstudio.com/blogs/2024/05/08/wasm)
- [公式ブログパート2](https://code.visualstudio.com/blogs/2024/06/07/wasm-part2) <- 今回はこちらを参照
- [Run WebAssemblies in VS Code for the Web](https://code.visualstudio.com/blogs/2023/06/05/vscode-wasm-wasi)
- [拡張機能開発](https://github.com/microsoft/vscode-extension-samples/tree/main/wasm-language-server)