2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

VS Code 拡張機能 × Wasm × Language Server

Posted at

近年、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 を使って実際の機能を体感してみましょう。

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

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

ファイル数カウント

定義ジャンプ機能

サンプルの実行準備

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

動作確認済み前提バージョン
  • 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拡張)

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

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

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

# クローン
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 の文字の上で右クリック、「定義へ移動」を選択しましょう。
ファイルの先頭まで移動します。

定義ジャンプ機能

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

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

ファイル数カウント

サンプルを読んでみる

フォルダ構造

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

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

  • client/src/extension.ts — 通常通りの拡張機能エントリポイント。
  • server/src/main.rs — Language Server の Rust 実装。
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

定義ジャンプ機能

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

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,
};

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

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();
}

ファイルカウント機能

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

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

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 化にチャレンジしてみてください。

参考文献

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?