エージェント型開発を探求する -

埋め込みプログラミング言語

Visual Studio Codeは、プログラミング言語に対して豊富な言語機能を提供しています。言語サーバー拡張機能ガイドで読んだように、あらゆるプログラミング言語をサポートする言語サーバーを作成することができます。しかし、埋め込み言語のサポートを有効にするには、より多くの努力が必要となります。

今日、以下のような埋め込み言語はますます増加しています。

  • HTML内のJavaScriptおよびCSS
  • JavaScript内のJSX
  • Vue、Handlebars、Razorなどのテンプレート言語における補間
  • PHP内のHTML

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

このガイドには、このような言語サーバーを構築するための2つのアプローチ(言語サービス (Language Services)リクエスト転送 (Request Forwarding))を説明する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言語サーバーの簡略版であり、HTMLとCSSのオートコンプリートおよびCSSの診断エラーを実装した 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 を含めるのは面倒です。

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

リクエスト転送

一言で言えば、リクエスト転送は言語サービスと同様に機能します。リクエスト転送アプローチも、言語サーバーリクエストを受け取り、仮想コンテンツを計算し、レスポンスを算出します。

主な違いは以下の通りです。

  • 言語サービスアプローチがライブラリを使用して言語サーバーのレスポンスを計算するのに対し、リクエスト転送はリクエストを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準拠のコードエディタ で再利用できる。
  • - 他の言語で書かれた言語サービスを組み込むのが難しい場合がある。
  • - 言語サービスの依存関係から新しい機能を取り入れるために、継続的なメンテナンスが必要。

リクエスト転送

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

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

© . This site is unofficial and not affiliated with Microsoft.