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 言語サーバーは 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ライブラリのバンドルが問題となる場合は、リクエスト転送アプローチを検討できます。