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

組み込みプログラミング言語

Visual Studio Code は、プログラミング言語向けの豊富な言語機能を提供します。言語サーバー拡張機能ガイド でお読みいただいたように、任意のプログラミング言語をサポートするために言語サーバーを作成できます。ただし、組み込み言語向けにこのようなサポートを有効にするには、より多くの労力が必要です。

今日では、以下のような組み込み言語の数が増加しています。

  • HTML 内の JavaScript と CSS
  • JavaScript 内の JSX
  • テンプレート言語 (例: Vue、Handlebars、Razor) における補間
  • PHP 内の HTML

このガイドでは、組み込み言語向けの言語機能の実装に焦点を当てています。組み込み言語のシンタックスハイライトを提供することに興味がある場合は、シンタックスハイライトガイド で情報を見つけることができます。

このガイドには、そのような言語サーバーを構築するための2つのアプローチを示す2つのサンプルが含まれています。言語サービスリクエスト転送です。両方のサンプルを確認し、それぞれの方法の長所と短所をまとめます。

両方のサンプルのソースコードは以下にあります。

これから構築する組み込み言語サーバーは次のとおりです。

sample

両方のサンプルは、説明のために新しい言語 html1 を提供します。.html1 ファイルを作成して、次の機能をテストできます。

  • HTML タグの補完
  • <style> タグ内の CSS の補完
  • CSS の診断 (言語サービスサンプルのみ)

言語サービス

言語サービスとは、単一の言語に対してプログラムによる言語機能を実装するライブラリです。言語サーバーは、組み込み言語を処理するために言語サービスを埋め込むことができます。

VS Code の HTML サポートの概要は次のとおりです。

  • 組み込みの html 拡張機能 は、HTML のシンタックスハイライトと言語設定のみを提供します。
  • 組み込みの html-language-features 拡張機能 には、HTML 用のプログラムによる言語機能を提供する HTML 言語サーバーが含まれています。
  • HTML 言語サーバーは、HTML をサポートするために vscode-html-languageservice を使用します。
  • CSS 言語サーバーは、HTML 内の CSS をサポートするために vscode-css-languageservice を使用します。

HTML 言語サーバーは、HTML ドキュメントを分析し、それを言語リージョンに分割し、対応する言語サービスを使用して言語サーバーリクエストを処理します。

  • <| での自動補完リクエストの場合、HTML 言語サーバーは HTML 言語サービスを使用して HTML 補完を提供します。
  • <style>.foo { | }</style> での自動補完リクエストの場合、HTML 言語サーバーは CSS 言語サービスを使用して CSS 補完を提供します。

HTML と CSS の自動補完、および CSS の診断エラーを実装する HTML 言語サーバーの簡略版である lsp-embedded-language-service サンプルを見てみましょう。

言語サービスのサンプル

: このサンプルは、プログラムによる言語機能のトピック言語サーバー拡張機能ガイドの知識があることを前提としています。このコードは lsp-sample をベースにしています。

ソースコードは microsoft/vscode-extension-samples で入手できます。

lsp-sample と比較して、クライアント側のコードは同じです。

前述のとおり、サーバーは埋め込みコンテンツを処理するためにドキュメントを異なる言語リージョンに分割します。

簡単な例を次に示します。

<div></div>
<style>.foo { }</style>

この場合、サーバーは <style> タグを検出し、.foo { } を CSS リージョンとしてマークします。

特定の位置での自動補完リクエストが与えられた場合、サーバーは以下のロジックを使用して応答を計算します。

  • 位置が任意のリージョン内にある場合
    • そのリージョンの言語を持つ仮想ドキュメントで処理し、他のすべてのリージョンは空白で置き換えます。
  • 位置がどのリージョン外にある場合
    • HTML の仮想ドキュメントで処理し、すべてのリージョンは空白で置き換えます。

例えば、この位置で自動補完を行う場合:

<div></div>
<style>.foo { | }</style>

サーバーは、その位置がリージョン内にあると判断し、以下のコンテンツを持つ仮想 CSS ドキュメントを計算します (█ はスペースを表します)。

███████████
███████.foo { | }████████

サーバーはその後、vscode-css-languageservice を使用してこのドキュメントを分析し、補完項目の一覧を計算します。コンテンツに HTML が含まれていないため、CSS 言語サービスは問題なく処理できます。CSS 以外のすべてのコンテンツを空白に置き換えることで、位置を手動でオフセットする手間を省くことができます。

補完リクエストを処理するサーバーコード

connection.onCompletion(async (textDocumentPosition, token) => {
  const document = documents.get(textDocumentPosition.textDocument.uri);
  if (!document) {
    return null;
  }

  const mode = languageModes.getModeAtPosition(document, textDocumentPosition.position);
  if (!mode || !mode.doComplete) {
    return CompletionList.create();
  }
  const doComplete = mode.doComplete!;

  return doComplete(document, textDocumentPosition.position);
});

CSS リージョンに該当するすべての言語サーバーリクエストの処理を担当する CSS モード

export function getCSSMode(
  cssLanguageService: CSSLanguageService,
  documentRegions: LanguageModelCache<HTMLDocumentRegions>
): LanguageMode {
  return {
    getId() {
      return 'css';
    },
    doComplete(document: TextDocument, position: Position) {
      // Get virtual CSS document, with all non-CSS code replaced with whitespace
      const embedded = documentRegions.get(document).getEmbeddedDocument('css');
      // Compute a response with vscode-css-languageservice
      const stylesheet = cssLanguageService.parseStylesheet(embedded);
      return cssLanguageService.doComplete(embedded, position, stylesheet);
    }
  };
}

これは組み込み言語を処理するためのシンプルで効果的なアプローチです。しかし、このアプローチにはいくつかの欠点があります。

  • 言語サーバーが依存する言語サービスを継続的に更新する必要があります。
  • 言語サーバーと同じ言語で書かれていない言語サービスを含めるのは難しい場合があります。例えば、PHP で書かれた PHP 言語サーバーが TypeScript で書かれた vscode-css-languageservice を含めるのは面倒に感じるでしょう。

次に、上記の問題を解決するリクエスト転送について説明します。

リクエスト転送

要するに、リクエスト転送は言語サービスと似たような方法で動作します。リクエスト転送アプローチも言語サーバーリクエストを受け取り、仮想コンテンツを計算し、応答を算出します。

主な違いは次のとおりです。

  • 言語サービスアプローチがライブラリを使用して言語サーバーの応答を計算するのに対し、リクエスト転送は、アクティブであり、組み込み言語の補完プロバイダーを登録している拡張機能を使用するために、リクエストを VS Code に送り返します。

再度、簡単な例を次に示します。

<div></div>
<style>.foo { | }</style>

自動補完は次のように行われます。

  • 言語クライアントは、workspace.registerTextDocumentContentProvider を使用して、embedded-content ドキュメントの仮想テキストドキュメントプロバイダーを登録します。
  • 言語クライアントは、<FILE_URI> の補完リクエストをハイジャックします。
  • 言語クライアントは、リクエスト位置が CSS リージョン内にあると判断します。
  • 言語クライアントは、embedded-content://css/<FILE_URI>.css のような新しい URI を構築します。
  • その後、言語クライアントは commands.executeCommand('vscode.executeCompletionItemProvider', ...) を呼び出します。
    • VS Code の CSS 言語サーバーがこのプロバイダーリクエストに応答します。
    • 仮想テキストドキュメントプロバイダーは、CSS 以外のすべてのコードが空白に置き換えられた仮想コンテンツを CSS 言語サーバーに提供します。
    • 言語クライアントは VS Code から応答を受け取り、それを応答として送信します。

このアプローチにより、CSS を理解するライブラリがコードに含まれていなくても、CSS の自動補完を計算できます。VS Code が CSS 言語サーバーを更新すると、コードを更新することなく最新の CSS 言語サポートを得られます。

では、サンプルコードを見ていきましょう。

リクエスト転送のサンプル

: このサンプルは、プログラムによる言語機能のトピック言語サーバー拡張機能ガイドの知識があることを前提としています。このコードは lsp-sample をベースにしています。

ソースコードは microsoft/vscode-extension-samples で入手できます。

ドキュメントの URI とその仮想ドキュメント間のマップを保持し、対応するリクエストにそれらを提供する。

const virtualDocumentContents = new Map<string, string>();

workspace.registerTextDocumentContentProvider('embedded-content', {
  provideTextDocumentContent: uri => {
    // Remove leading `/` and ending `.css` to get original URI
    const originalUri = uri.path.slice(1).slice(0, -4);
    const decodedUri = decodeURIComponent(originalUri);
    return virtualDocumentContents.get(decodedUri);
  }
});

言語クライアントの middleware オプションを使用することで、自動補完のリクエストをハイジャックします。

let clientOptions: LanguageClientOptions = {
  documentSelector: [{ scheme: 'file', language: 'html' }],
  middleware: {
    provideCompletionItem: async (document, position, context, token, next) => {
      // If not in `<style>`, do not perform request forwarding
      if (
        !isInsideStyleRegion(
          htmlLanguageService,
          document.getText(),
          document.offsetAt(position)
        )
      ) {
        return await next(document, position, context, token);
      }

      const originalUri = document.uri.toString(true);
      virtualDocumentContents.set(
        originalUri,
        getCSSVirtualContent(htmlLanguageService, document.getText())
      );

      const vdocUriString = `embedded-content://css/${encodeURIComponent(originalUri)}.css`;
      const vdocUri = Uri.parse(vdocUriString);
      return await commands.executeCommand<CompletionList>(
        'vscode.executeCompletionItemProvider',
        vdocUri,
        position,
        context.triggerCharacter
      );
    }
  }
};

潜在的な問題点

組み込み言語サーバーの実装中に、多くの問題に遭遇しました。まだ完璧な解決策はありませんが、皆様も同様の問題に遭遇する可能性が高いため、事前にお知らせしておきたいと思います。

言語機能の実装が難しい

一般的に、言語リージョン境界をまたいで機能する言語機能の実装はより困難です。例えば、自動補完やホバーコンテンツは、埋め込みコンテンツの言語を検出し、その埋め込みコンテンツに基づいて応答を計算できるため、実装が容易です。しかし、フォーマットや名前変更などの言語機能は、特別な処理が必要になる場合があります。フォーマットの場合、単一のドキュメント内の複数のリージョンに対してインデントやフォーマッター設定を処理する必要があります。名前変更の場合、異なるドキュメント内の異なるリージョン間で機能させることは困難な場合があります。

言語サービスはステートフルであり、埋め込みが難しい場合がある

VS Code の HTML サポートは、HTML、CSS、JavaScript の言語機能を提供します。HTML および CSS 言語サービスはステートレスですが、JavaScript 言語機能を動かす TypeScript サーバーはステートフルです。HTML ドキュメント内では基本的な JavaScript サポートしか提供していません。これは、TypeScript にプロジェクトの状態を伝えるのが難しいためです。例えば、CDN でホストされている lodash ライブラリを指す <script> タグを含めても、<script> タグ内で _. の補完は得られません。

エンコーディングとデコーディング

ドキュメントの主要言語は、その組み込み言語とは異なるエンコーディングまたはエスケープルールを持つ場合があります。例えば、この HTML ドキュメントは HTML 仕様によると無効です。

<SCRIPT type="text/javascript">
  document.write ("<EM>This won't work</EM>")
</SCRIPT>

この場合、組み込み JavaScript 用の言語サーバーが </ を含む結果を返す場合、それは <\/ にエスケープされるべきです。

まとめ

どちらのアプローチにも長所と短所があります。

言語サービス

  • + 言語サーバーとユーザーエクスペリエンスを完全に制御できる。
  • + 他の言語サーバーへの依存がない。すべてのコードが1つのリポジトリにまとまっている。
  • + すべての LSP 準拠のコードエディターで言語サーバーを再利用できる。
  • - 他の言語で書かれた言語サービスを埋め込むのが難しい場合がある。
  • - 言語サービスの依存関係から新しい機能を得るために継続的なメンテナンスが必要。

リクエスト転送

  • + 言語サーバーの言語で書かれていない言語サービスを埋め込む際の問題を回避できる (例: Razor 言語サーバーに C# コンパイラを埋め込んで C# をサポートする)。
  • + 他の言語サービスからの新しい機能を上流から取り込むためにメンテナンスは不要。
  • - 診断エラーでは機能しない。VS Code API は、診断を「プル」(リクエスト) できる診断プロバイダーをサポートしていない。
  • - 制御の欠如により、他の言語サーバーと状態を共有するのが難しい。
  • - クロス言語機能の実装が難しい場合がある (例: <div class="foo"> が存在する場合に .foo の CSS 補完を提供する)。

全体として、このアプローチはユーザーエクスペリエンスをより細かく制御でき、言語サーバーが任意の LSP 準拠エディターで再利用可能であるため、言語サービスを埋め込むことによる言語サーバーの構築をお勧めします。ただし、コンテキストや言語サーバーの状態なしで埋め込みコンテンツを簡単に処理できる単純なユースケースがある場合、または Node.js ライブラリのバンドルが問題となる場合は、リクエスト転送のアプローチを検討できます。