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

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 言語サーバーは 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
      );
    }
  }
};

潜在的な課題

埋め込み言語サーバーを実装する際、私たちは多くの問題に直面しました。まだ完璧な解決策はありませんが、あなたも同様の問題に直面する可能性が高いため、事前にお知らせしておきます。

言語機能の実装が困難

一般的に、言語領域の境界をまたいで動作する言語機能は実装が困難です。例えば、自動補完やホバーコンテンツは、埋め込みコンテンツの言語を検出し、それに基づいてレスポンスを計算できるため簡単に実装できます。しかし、フォーマットやリネームなどの言語機能には、特別な処理が必要になる場合があります。フォーマットの場合、1つのドキュメント内にある複数の領域のインデントとフォーマッタの設定を処理する必要があります。リネームの場合、異なるドキュメント内の異なる領域にわたって動作させるのが難しい場合があります。

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

VS Code の HTML サポートは、HTML、CSS、JavaScript の言語機能を提供します。HTML および CSS の言語サービスはステートレスですが、JavaScript の言語機能を支える TypeScript サーバーはステートフルです。プロジェクトの状態を TypeScript に伝えるのが難しいため、HTML ドキュメント内での JavaScript サポートは基本的なものに限定されています。例えば、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 website operates independently and is not affiliated with or endorsed by Microsoft. All brand names, logos, and trademarks are the property of their respective owners.