🚀 VS Code で ください!

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

Visual Studio Code は、プログラミング言語向けの豊富な言語機能を提供しています。「Language Server 拡張機能ガイド」でお読みになったように、言語サーバーを記述して、あらゆるプログラミング言語をサポートできます。ただし、埋め込み言語のサポートを有効にするには、より多くの労力が必要です。

今日では、次のような埋め込み言語が増えています。

  • 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サンプルを見てみましょう。

言語サービスサンプル

**注**: このサンプルは、「プログラミング言語機能」トピックと「Language Server 拡張機能ガイド」の知識があることを前提としています。コードは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 言語サポートが得られます。

それでは、サンプルコードを確認しましょう。

リクエスト転送サンプル

**注**: このサンプルは、「プログラミング言語機能」トピックと「Language Server 拡張機能ガイド」の知識があることを前提としています。コードは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 サーバーはステートフルです。プロジェクトの状態を 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 ライブラリのバンドルが問題になる場合は、リクエスト転送アプローチを検討できます。