近年、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
- Node.js >= v22.14.0
- Rust compiler toolchain
- wasm-tools
- wit-bindgen
- WASI based WebAssembly Execution Engine
動作確認済み前提バージョン
- 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 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
定義ジャンプ機能
定義ジャンプ機能: サーバーサイド
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,
};
定義ジャンプ機能: クライアントサイド
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();
}
ファイルカウント機能
ファイルカウント機能: サーバーサイド
#[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(¶ms.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
// 現在開いているフォルダの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 <- 今回はこちらを参照
- Run WebAssemblies in VS Code for the Web
- 拡張機能開発