VS Codeのエージェントモードを拡張するには、を試してください!

拡張機能開発にWebAssemblyを利用する - パート2

2024年6月7日 by Dirk Bäumer

前回の拡張機能開発にWebAssemblyを利用するに関するブログ記事では、コンポーネントモデルを使用してWebAssemblyコードをVisual Studio Code拡張機能に統合する方法を実演しました。今回のブログ記事では、さらに2つの独立したユースケースに焦点を当てます。(a) 拡張機能ホストのメインスレッドをブロックしないようにワーカーでWebAssemblyコードを実行すること、(b) WebAssemblyにコンパイルされる言語を使用して言語サーバーを作成することです。

このブログ記事の例を実行するには、次のツールが必要です: VS Code、Node.js、Rustコンパイラツールチェーンwasm-toolswit-bindgen

ワーカーでWebAssemblyコードを実行する

前回のブログ記事の例では、WebAssemblyコードをVS Code拡張機能ホストのメインスレッドで実行しました。これは実行時間が短い限り問題ありません。しかし、実行時間の長い処理は、拡張機能ホストのメインスレッドを他の拡張機能が利用できるようにするために、ワーカーで実行する必要があります。

VS Codeコンポーネントモデルは、ワーカー側と拡張機能のメイン側の両方で必要なグルーコードを自動生成できるようにすることで、これを容易にするメタモデルを提供します。

以下のコードスニペットは、ワーカーに必要なコードを示しています。この例では、コードがworker.tsという名前のファイルに保存されていることを前提としています。

import { Connection, RAL } from '@vscode/wasm-component-model';
import { calculator } from './calculator';

async function main(): Promise<void> {
  const connection = await Connection.createWorker(calculator._);
  connection.listen();
}

main().catch(RAL().console.error);

このコードは、拡張機能ホストのメインワーカーと通信するための接続を作成し、wit2tsツールによって生成されたcalculatorワールドで接続を初期化します。

拡張機能側では、WebAssemblyモジュールをロードし、同様にcalculatorワールドにバインドします。計算を実行するための対応する呼び出しは、実行がワーカーで非同期に行われるため、awaitする必要があります(例:await api.calc(...))。

// The channel for printing the result.
const channel = vscode.window.createOutputChannel('Calculator');
context.subscriptions.push(channel);

// The channel for printing the log.
const log = vscode.window.createOutputChannel('Calculator - Log', { log: true });
context.subscriptions.push(log);

// The implementation of the log function that is called from WASM
const service: calculator.Imports.Promisified = {
  log: async (msg: string): Promise<void> => {
    // Wait 100ms to slow things down :-)
    await new Promise(resolve => setTimeout(resolve, 100));
    log.info(msg);
  }
};

// Load the WASM model
const filename = vscode.Uri.joinPath(
  context.extensionUri,
  'target',
  'wasm32-unknown-unknown',
  'debug',
  'calculator.wasm'
);
const bits = await vscode.workspace.fs.readFile(filename);
const module = await WebAssembly.compile(bits);

// Create the worker
const worker = new Worker(
  vscode.Uri.joinPath(context.extensionUri, './out/worker.js').fsPath
);
// Bind the world to the worker
const api = await calculator._.bind(service, module, worker);

vscode.commands.registerCommand(
  'vscode-samples.wasm-component-model-async.run',
  async () => {
    channel.show();
    channel.appendLine('Running calculator example');
    const add = Types.Operation.Add({ left: 1, right: 2 });
    channel.appendLine(`Add ${await api.calc(add)}`);
    const sub = Types.Operation.Sub({ left: 10, right: 8 });
    channel.appendLine(`Sub ${await api.calc(sub)}`);
    const mul = Types.Operation.Mul({ left: 3, right: 7 });
    channel.appendLine(`Mul ${await api.calc(mul)}`);
    const div = Types.Operation.Div({ left: 10, right: 2 });
    channel.appendLine(`Div ${await api.calc(div)}`);
  }
);

注意すべき重要な点がいくつかあります

  • この例で使用されているWITファイルは、前回のブログ記事のRustでの計算機の例で使用されたものと何ら変わりありません。
  • WebAssemblyコードの実行はワーカー内で行われるため、インポートされたサービス(例えば上記のlog関数)の実装はPromiseを返すことができますが、必須ではありません。
  • WebAssemblyは現在、同期実行モデルのみをサポートしています。その結果、WebAssemblyコードを実行するワーカーから拡張機能ホストのメインスレッドへのインポートされたサービスを呼び出す各呼び出しには、次のステップが必要です
    1. 呼び出すサービスを記述したメッセージを拡張機能ホストのメインスレッドに投稿します(例:log関数を呼び出す)。
    2. Atomics.waitを使用してワーカーの実行を中断します。
    3. 拡張機能ホストのメインスレッドでメッセージを処理します。
    4. Atomics.notifyを使用してワーカーを再開し、結果を通知します。

この同期処理は、測定可能な時間的オーバーヘッドを追加します。これらのステップはすべてコンポーネントモデルによって透過的に処理されますが、開発者はこの点を認識し、インポートするAPIサーフェスを設計する際に考慮する必要があります。

この例の完全なソースコードは、VS Code拡張機能サンプルリポジトリにあります。

WebAssemblyベースの言語サーバー

VS Code for the WebのWebAssemblyサポートに取り組み始めたとき、我々が想定したユースケースの1つは、WebAssemblyを使用して言語サーバーを実行することでした。VS CodeのLSPライブラリへの最新の変更と、WebAssemblyとLSPを橋渡しするための新しいモジュールの導入により、WebAssembly言語サーバーの実装は、オペレーティングシステムのプロセスとして実装するのと同じくらい簡単になりました。

さらに、WebAssembly言語サーバーは、WASI Preview 1を完全にサポートするWebAssembly Core Extension上で実行されます。これは、ファイルがGitHubリポジトリなどのリモートに保存されている場合でも、言語サーバーがプログラミング言語の通常のファイルシステムAPIを使用してワークスペース内のファイルにアクセスできることを意味します。

以下のコードスニペットは、lsp_serverクレートのサンプルサーバーに基づいたRust言語サーバーを示しています。この言語サーバーは言語分析を行わず、単にGotoDefinitionリクエストに対して事前定義された結果を返すだけです。

match cast::<GotoDefinition>(req) {
    Ok((id, params)) => {
        let uri = params.text_document_position_params.text_document.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);
        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;
    }
    Err(err @ ExtractError::JsonError { .. }) => panic!("{err:?}"),
    Err(ExtractError::MethodMismatch(req)) => req,
};

この言語サーバーの完全なソースコードは、VS Codeサンプルリポジトリにあります。

新しい@vscode/wasm-wasi-lsp npmモジュールを使用すると、拡張機能のTypeScriptコード内にWebAssembly言語サーバーを作成できます。WebAssemblyコードをWASIサポート付きのワーカーとしてインスタンス化するには、Run WebAssemblies in VS Code for the Webブログ記事で詳しく説明されているWebAssembly Core Extensionを使用します。

拡張機能のTypeScriptコードも単純です。プレーンテキストファイル用にサーバーを登録します。

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');
  // The server options to run the WebAssembly language server.
  const serverOptions: ServerOptions = async () => {
    const options: ProcessOptions = {
      stdio: createStdioOptions(),
      mountPoints: [{ kind: 'workspaceFolder' }]
    };

    // Load the WebAssembly code
    const filename = Uri.joinPath(
      context.extensionUri,
      'server',
      'target',
      'wasm32-wasip1-threads',
      'release',
      'server.wasm'
    );
    const bits = await workspace.fs.readFile(filename);
    const module = await WebAssembly.compile(bits);

    // Create the wasm worker that runs the LSP server
    const process = await wasm.createProcess(
      'lsp-server',
      module,
      { initial: 160, maximum: 160, shared: true },
      options
    );

    // Hook stderr to the output channel
    const decoder = new TextDecoder('utf-8');
    process.stderr!.onData(data => {
      channel.append(decoder.decode(data));
    });

    return startServer(process);
  };

  const clientOptions: LanguageClientOptions = {
    documentSelector: [{ language: 'plaintext' }],
    outputChannel: channel,
    uriConverters: createUriConverters()
  };

  let client = new LanguageClient('lspClient', 'LSP Client', serverOptions, clientOptions);
  await client.start();
}

このコードを実行すると、プレーンテキストファイルのコンテキストメニューにGoto Definitionエントリが追加されます。このアクションを実行すると、対応するリクエストがLSPサーバーに送信されます。

Running the goto definition action

@vscode/wasm-wasi-lsp npmモジュールが、ドキュメントURIをワークスペースの値からWASI Preview 1ホストで認識される値に自動的に変換することに注意することが重要です。上記の例では、VS Code内のテキストドキュメントのURIは通常vscode-vfs://github/dbaeumer/plaintext-sample/lorem.txtのようなものですが、この値はWASIホスト内で認識されるfile:///workspace/lorem.txtに変換されます。この変換は、言語サーバーがURIをVS Codeに送り返すときにも自動的に行われます。

ほとんどの言語サーバーライブラリはカスタムメッセージをサポートしており、Language Server Protocol Specificationにまだ存在しない機能を言語サーバーに簡単に追加できます。以下のコードスニペットは、指定されたワークスペースフォルダ内のファイルを数えるためのカスタムメッセージハンドラを、先ほど使用したRust言語サーバーに追加する方法を示しています。

#[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)) => {
				eprintln!("Received countFiles request #{} {}", id, params.folder);
        		let result = count_files_in_directory(&params.folder.path());
        		let json = serde_json::to_value(&result).unwrap();
        		let resp = Response { id, result: Some(json), error: None };
        		connection.sender.send(Message::Response(resp))?;
        		continue;
    		}
    		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()
}

このカスタムリクエストをLSPサーバーに送信するTypeScriptコードは次のようになります。

const folder = workspace.workspaceFolders![0].uri;
const result = await client.sendRequest(CountFilesRequest, {
  folder: client.code2ProtocolConverter.asUri(folder)
});
window.showInformationMessage(`The workspace contains ${result} files.`);

これをvscode-languageserverリポジトリで実行すると、次の通知が表示されます。

Running count all files

Language Server Protocol仕様で指定されている機能を言語サーバーが実装する必要は必ずしもないことに注意してください。拡張機能がWASI Preview 1ターゲットにのみコンパイルできるライブラリコードを統合したい場合、VS Codeがコンポーネントモデルの実装でWASI 0.2 previewをサポートするまで、カスタムメッセージを持つ言語サーバーを実装することが良い選択肢となる可能性があります。

今後の予定

前回のブログ記事で述べたように、私たちはVS Code向けのWASI 0.2 previewを実装する取り組みを続けています。また、WASMにコンパイルされるRust以外の言語を含むようにコード例を広げることも計画しています。

よろしくお願いいたします。

DirkとVS Codeチーム

ハッピーコーディング!