🚀 VS Code で しましょう!

Language Server Extension Guide

プログラムによる言語機能のトピックでご覧になったように、languages.* API を直接使用して言語機能を実装することは可能です。しかし、Language Server Extension は、そのような言語サポートを実装する別の方法を提供します。

このトピックでは

  • Language Server Extension の利点について説明します。
  • Microsoft/vscode-languageserver-node ライブラリを使用して Language Server を構築する手順を説明します。 lsp-sample のコードに直接ジャンプすることもできます。

なぜ Language Server が必要か?

Language Server は、多くのプログラミング言語の編集エクスペリエンスを強化する特別な種類の Visual Studio Code 拡張機能です。 Language Server を使用すると、VS Code でサポートされているオートコンプリート、エラーチェック (診断)、定義へのジャンプ、その他多くの言語機能を実装できます。

ただし、VS Code での言語機能のサポートを実装する際に、3 つの共通の問題が見つかりました。

第一に、Language Server は通常、ネイティブプログラミング言語で実装されており、Node.js ランタイムを持つ VS Code と統合する上で課題があります。

さらに、言語機能はリソースを大量に消費する可能性があります。たとえば、ファイルを正しく検証するには、Language Server は大量のファイルを解析し、それらの抽象構文木 (AST) を構築し、静的プログラム分析を実行する必要があります。これらの操作は、CPU とメモリの使用量を大幅に増加させる可能性があり、VS Code のパフォーマンスに影響を与えないようにする必要があります。

最後に、複数の言語ツールを複数のコードエディターと統合するには、多大な労力がかかる可能性があります。言語ツールの観点からは、異なる API を持つコードエディターに適応する必要があります。コードエディターの観点からは、言語ツールから統一された API を期待することはできません。これにより、M 個の言語の言語サポートを N 個のコードエディターに実装するには、M * N の作業が必要になります。

これらの問題を解決するために、マイクロソフトはLanguage Server Protocolを規定しました。これは、言語ツールとコードエディター間の通信を標準化するものです。これにより、Language Server は任意の言語で実装でき、独自のプロセスで実行してパフォーマンスコストを回避できます。 Language Server Protocol を介してコードエディターと通信するためです。さらに、LSP 準拠の言語ツールは複数の LSP 準拠のコードエディターと統合でき、LSP 準拠のコードエディターは複数の LSP 準拠の言語ツールを簡単に選択できます。 LSP は、言語ツールプロバイダーとコードエディターベンダーの両方にとってメリットがあります!

LSP Languages and Editors

このガイドでは、以下について説明します。

  • 提供されているNode SDKを使用して、VS Code で Language Server 拡張機能を構築する方法について説明します。
  • Language Server 拡張機能を実行、デバッグ、ログ記録、およびテストする方法について説明します。
  • Language Server に関する高度なトピックを紹介します。

Language Server の実装

概要

VS Code では、Language Server には 2 つの部分があります。

  • Language Client: JavaScript / TypeScript で記述された通常の VS Code 拡張機能。この拡張機能は、すべてのVS Code Namespace APIにアクセスできます。
  • Language Server: 別のプロセスで実行される言語分析ツール。

上記で簡単に述べたように、Language Server を別のプロセスで実行することには 2 つの利点があります。

  • 分析ツールは、Language Server Protocol に従って Language Client と通信できる限り、任意の言語で実装できます。
  • 言語分析ツールは CPU とメモリの使用量が大きくなることが多いため、別のプロセスで実行することでパフォーマンスコストを回避できます。

これは、2 つの Language Server 拡張機能を実行している VS Code の図です。 HTML Language Client と PHP Language Client は、TypeScript で記述された通常の VS Code 拡張機能です。それぞれが対応する Language Server をインスタンス化し、LSP を介してそれらと通信します。 PHP Language Server は PHP で記述されていますが、LSP を介して PHP Language Client と通信できます。

LSP Illustration

このガイドでは、Node SDKを使用して Language Client / Server を構築する方法について説明します。残りのドキュメントは、VS Code Extension APIに精通していることを前提としています。

LSP サンプル - プレーンテキストファイル用のシンプルな Language Server

プレーンテキストファイルのオートコンプリートと診断を実装するシンプルな Language Server 拡張機能を構築してみましょう。クライアント/サーバー間の構成の同期についても説明します。

すぐにコードに飛び込みたい場合は

  • lsp-sample: このガイド用に詳細にドキュメント化されたソースコード。
  • lsp-multi-server-sample: マルチルートワークスペース機能を VS Code でサポートするために、ワークスペースフォルダーごとに異なるサーバーインスタンスを起動する、詳細にドキュメント化された lsp-sample の高度なバージョン。

リポジトリ Microsoft/vscode-extension-samples をクローンしてサンプルを開きます。

> git clone https://github.com/microsoft/vscode-extension-samples.git
> cd vscode-extension-samples/lsp-sample
> npm install
> npm run compile
> code .

上記はすべての依存関係をインストールし、クライアントとサーバーの両方のコードを含む lsp-sample ワークスペースを開きます。以下は、lsp-sample の構造の概要です。

.
├── client // Language Client
│   ├── src
│   │   ├── test // End to End tests for Language Client / Server
│   │   └── extension.ts // Language Client entry point
├── package.json // The extension manifest
└── server // Language Server
    └── src
        └── server.ts // Language Server entry point

'Language Client' の説明

まず、Language Client の機能について説明する /package.json を見てみましょう。興味深いセクションが 2 つあります。

まず、configuration セクションを見てください。

"configuration": {
    "type": "object",
    "title": "Example configuration",
    "properties": {
        "languageServerExample.maxNumberOfProblems": {
            "scope": "resource",
            "type": "number",
            "default": 100,
            "description": "Controls the maximum number of problems produced by the server."
        }
    }
}

このセクションでは、configuration 設定を VS Code に提供します。この例では、これらの設定が起動時および設定のすべての変更時に Language Server に送信される方法について説明します。

: 拡張機能が 1.74.0 より前の VS Code バージョンと互換性がある場合は、/package.jsonactivationEvents フィールドで onLanguage:plaintext を宣言して、プレーンテキストファイル (たとえば、拡張子 .txt のファイル) が開かれるとすぐに拡張機能をアクティブ化するように VS Code に指示する必要があります。

"activationEvents": []

実際の Language Client ソースコードと対応する package.json/client フォルダーにあります。 /client/package.json ファイルの興味深い部分は、engines フィールドを介して vscode 拡張機能ホスト API を参照し、vscode-languageclient ライブラリへの依存関係を追加していることです。

"engines": {
    "vscode": "^1.52.0"
},
"dependencies": {
    "vscode-languageclient": "^7.0.0"
}

前述のように、クライアントは通常の VS Code 拡張機能として実装されており、すべての VS Code 名前空間 API にアクセスできます。

以下は、lsp-sample 拡張機能のエントリである対応する extension.ts ファイルの内容です。

import * as path from 'path';
import { workspace, ExtensionContext } from 'vscode';

import {
  LanguageClient,
  LanguageClientOptions,
  ServerOptions,
  TransportKind
} from 'vscode-languageclient/node';

let client: LanguageClient;

export function activate(context: ExtensionContext) {
  // The server is implemented in node
  let serverModule = context.asAbsolutePath(path.join('server', 'out', 'server.js'));
  // The debug options for the server
  // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging
  let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };

  // If the extension is launched in debug mode then the debug server options are used
  // Otherwise the run options are used
  let serverOptions: ServerOptions = {
    run: { module: serverModule, transport: TransportKind.ipc },
    debug: {
      module: serverModule,
      transport: TransportKind.ipc,
      options: debugOptions
    }
  };

  // Options to control the language client
  let clientOptions: LanguageClientOptions = {
    // Register the server for plain text documents
    documentSelector: [{ scheme: 'file', language: 'plaintext' }],
    synchronize: {
      // Notify the server about file changes to '.clientrc files contained in the workspace
      fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
    }
  };

  // Create the language client and start the client.
  client = new LanguageClient(
    'languageServerExample',
    'Language Server Example',
    serverOptions,
    clientOptions
  );

  // Start the client. This will also launch the server
  client.start();
}

export function deactivate(): Thenable<void> | undefined {
  if (!client) {
    return undefined;
  }
  return client.stop();
}

'Language Server' の説明

注: GitHub リポジトリからクローンされた 'Server' 実装には、最終的なウォークスルー実装が含まれています。ウォークスルーに従うには、新しい server.ts を作成するか、クローンされたバージョンの内容を変更できます。

この例では、サーバーも TypeScript で実装され、Node.js を使用して実行されます。 VS Code には Node.js ランタイムが既に同梱されているため、ランタイムに特定の要件がない限り、独自のランタイムを提供する必要はありません。

Language Server のソースコードは /server にあります。サーバーの package.json ファイルの興味深いセクションは次のとおりです。

"dependencies": {
    "vscode-languageserver": "^7.0.0",
    "vscode-languageserver-textdocument": "^1.0.1"
}

これにより、vscode-languageserver ライブラリがプルインされます。

以下は、VS Code からサーバーに常に増分デルタを送信することにより、テキストドキュメントを同期する、提供されているテキストドキュメントマネージャーを使用するサーバー実装です。

import {
  createConnection,
  TextDocuments,
  Diagnostic,
  DiagnosticSeverity,
  ProposedFeatures,
  InitializeParams,
  DidChangeConfigurationNotification,
  CompletionItem,
  CompletionItemKind,
  TextDocumentPositionParams,
  TextDocumentSyncKind,
  InitializeResult
} from 'vscode-languageserver/node';

import { TextDocument } from 'vscode-languageserver-textdocument';

// Create a connection for the server, using Node's IPC as a transport.
// Also include all preview / proposed LSP features.
let connection = createConnection(ProposedFeatures.all);

// Create a simple text document manager.
let documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);

let hasConfigurationCapability: boolean = false;
let hasWorkspaceFolderCapability: boolean = false;
let hasDiagnosticRelatedInformationCapability: boolean = false;

connection.onInitialize((params: InitializeParams) => {
  let capabilities = params.capabilities;

  // Does the client support the `workspace/configuration` request?
  // If not, we fall back using global settings.
  hasConfigurationCapability = !!(
    capabilities.workspace && !!capabilities.workspace.configuration
  );
  hasWorkspaceFolderCapability = !!(
    capabilities.workspace && !!capabilities.workspace.workspaceFolders
  );
  hasDiagnosticRelatedInformationCapability = !!(
    capabilities.textDocument &&
    capabilities.textDocument.publishDiagnostics &&
    capabilities.textDocument.publishDiagnostics.relatedInformation
  );

  const result: InitializeResult = {
    capabilities: {
      textDocumentSync: TextDocumentSyncKind.Incremental,
      // Tell the client that this server supports code completion.
      completionProvider: {
        resolveProvider: true
      }
    }
  };
  if (hasWorkspaceFolderCapability) {
    result.capabilities.workspace = {
      workspaceFolders: {
        supported: true
      }
    };
  }
  return result;
});

connection.onInitialized(() => {
  if (hasConfigurationCapability) {
    // Register for all configuration changes.
    connection.client.register(DidChangeConfigurationNotification.type, undefined);
  }
  if (hasWorkspaceFolderCapability) {
    connection.workspace.onDidChangeWorkspaceFolders(_event => {
      connection.console.log('Workspace folder change event received.');
    });
  }
});

// The example settings
interface ExampleSettings {
  maxNumberOfProblems: number;
}

// The global settings, used when the `workspace/configuration` request is not supported by the client.
// Please note that this is not the case when using this server with the client provided in this example
// but could happen with other clients.
const defaultSettings: ExampleSettings = { maxNumberOfProblems: 1000 };
let globalSettings: ExampleSettings = defaultSettings;

// Cache the settings of all open documents
let documentSettings: Map<string, Thenable<ExampleSettings>> = new Map();

connection.onDidChangeConfiguration(change => {
  if (hasConfigurationCapability) {
    // Reset all cached document settings
    documentSettings.clear();
  } else {
    globalSettings = <ExampleSettings>(
      (change.settings.languageServerExample || defaultSettings)
    );
  }

  // Revalidate all open text documents
  documents.all().forEach(validateTextDocument);
});

function getDocumentSettings(resource: string): Thenable<ExampleSettings> {
  if (!hasConfigurationCapability) {
    return Promise.resolve(globalSettings);
  }
  let result = documentSettings.get(resource);
  if (!result) {
    result = connection.workspace.getConfiguration({
      scopeUri: resource,
      section: 'languageServerExample'
    });
    documentSettings.set(resource, result);
  }
  return result;
}

// Only keep settings for open documents
documents.onDidClose(e => {
  documentSettings.delete(e.document.uri);
});

// The content of a text document has changed. This event is emitted
// when the text document first opened or when its content has changed.
documents.onDidChangeContent(change => {
  validateTextDocument(change.document);
});

async function validateTextDocument(textDocument: TextDocument): Promise<void> {
  // In this simple example we get the settings for every validate run.
  let settings = await getDocumentSettings(textDocument.uri);

  // The validator creates diagnostics for all uppercase words length 2 and more
  let text = textDocument.getText();
  let pattern = /\b[A-Z]{2,}\b/g;
  let m: RegExpExecArray | null;

  let problems = 0;
  let diagnostics: Diagnostic[] = [];
  while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
    problems++;
    let diagnostic: Diagnostic = {
      severity: DiagnosticSeverity.Warning,
      range: {
        start: textDocument.positionAt(m.index),
        end: textDocument.positionAt(m.index + m[0].length)
      },
      message: `${m[0]} is all uppercase.`,
      source: 'ex'
    };
    if (hasDiagnosticRelatedInformationCapability) {
      diagnostic.relatedInformation = [
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Spelling matters'
        },
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Particularly for names'
        }
      ];
    }
    diagnostics.push(diagnostic);
  }

  // Send the computed diagnostics to VS Code.
  connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

connection.onDidChangeWatchedFiles(_change => {
  // Monitored files have change in VS Code
  connection.console.log('We received a file change event');
});

// This handler provides the initial list of the completion items.
connection.onCompletion(
  (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
    // The pass parameter contains the position of the text document in
    // which code complete got requested. For the example we ignore this
    // info and always provide the same completion items.
    return [
      {
        label: 'TypeScript',
        kind: CompletionItemKind.Text,
        data: 1
      },
      {
        label: 'JavaScript',
        kind: CompletionItemKind.Text,
        data: 2
      }
    ];
  }
);

// This handler resolves additional information for the item selected in
// the completion list.
connection.onCompletionResolve(
  (item: CompletionItem): CompletionItem => {
    if (item.data === 1) {
      item.detail = 'TypeScript details';
      item.documentation = 'TypeScript documentation';
    } else if (item.data === 2) {
      item.detail = 'JavaScript details';
      item.documentation = 'JavaScript documentation';
    }
    return item;
  }
);

// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);

// Listen on the connection
connection.listen();

簡単な検証の追加

ドキュメントの検証をサーバーに追加するには、テキストドキュメントの内容が変更されるたびに呼び出されるテキストドキュメントマネージャーにリスナーを追加します。ドキュメントを検証する最適なタイミングを決定するのはサーバー次第です。例の実装では、サーバーはプレーンテキストドキュメントを検証し、すべて大文字の単語のすべての出現箇所にフラグを立てます。対応するコードスニペットは次のようになります。

// The content of a text document has changed. This event is emitted
// when the text document first opened or when its content has changed.
documents.onDidChangeContent(async change => {
  let textDocument = change.document;
  // In this simple example we get the settings for every validate run.
  let settings = await getDocumentSettings(textDocument.uri);

  // The validator creates diagnostics for all uppercase words length 2 and more
  let text = textDocument.getText();
  let pattern = /\b[A-Z]{2,}\b/g;
  let m: RegExpExecArray | null;

  let problems = 0;
  let diagnostics: Diagnostic[] = [];
  while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
    problems++;
    let diagnostic: Diagnostic = {
      severity: DiagnosticSeverity.Warning,
      range: {
        start: textDocument.positionAt(m.index),
        end: textDocument.positionAt(m.index + m[0].length)
      },
      message: `${m[0]} is all uppercase.`,
      source: 'ex'
    };
    if (hasDiagnosticRelatedInformationCapability) {
      diagnostic.relatedInformation = [
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Spelling matters'
        },
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Particularly for names'
        }
      ];
    }
    diagnostics.push(diagnostic);
  }

  // Send the computed diagnostics to VS Code.
  connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
});

診断のヒントとコツ

  • 開始位置と終了位置が同じ場合、VS Code はその位置の単語を波線で下線を引きます。
  • 行末まで波線で下線を引く場合は、終了位置の文字を Number.MAX_VALUE に設定します。

Language Server を実行するには、次の手順を実行します。

  • ⇧⌘B (Windows, Linux Ctrl+Shift+B) を押して、ビルドタスクを開始します。このタスクは、クライアントとサーバーの両方をコンパイルします。
  • [実行とデバッグ] ビューを開き、[クライアントの起動] 起動構成を選択し、[デバッグの開始] ボタンを押して、拡張機能コードを実行する VS Code の追加の [拡張機能開発ホスト] インスタンスを起動します。
  • ルートフォルダーに test.txt ファイルを作成し、次のコンテンツを貼り付けます。
TypeScript lets you write JavaScript the way you really want to.
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.
ANY browser. ANY host. ANY OS. Open Source.

[拡張機能開発ホスト] インスタンスは次のようになります。

Validating a text file

クライアントとサーバーの両方のデバッグ

クライアントコードのデバッグは、通常の拡張機能をデバッグするのと同じくらい簡単です。クライアントコードにブレークポイントを設定し、F5 を押して拡張機能をデバッグします。

Debugging the client

サーバーは拡張機能 (クライアント) で実行されている LanguageClient によって起動されるため、実行中のサーバーにデバッガーをアタッチする必要があります。これを行うには、[実行とデバッグ] ビューに切り替え、[サーバーにアタッチ] 起動構成を選択し、F5 を押します。これにより、デバッガーがサーバーにアタッチされます。

Debugging the server

Language Server のログ記録のサポート

vscode-languageclient を使用してクライアントを実装している場合は、Language Client / Server 間の通信を Language Client の name のチャネルにログ記録するように Client に指示する設定 [langId].trace.server を指定できます。

lsp-sample の場合、この設定 "languageServerExample.trace.server": "verbose" を設定できます。次に、[Language Server Example] チャネルに移動します。ログが表示されます。

LSP Log

サーバーでの構成設定の使用

拡張機能のクライアント部分を作成するときに、報告される問題の最大数を制御する設定を既に定義しました。また、サーバー側でこれらの設定をクライアントから読み取るコードも作成しました。

function getDocumentSettings(resource: string): Thenable<ExampleSettings> {
  if (!hasConfigurationCapability) {
    return Promise.resolve(globalSettings);
  }
  let result = documentSettings.get(resource);
  if (!result) {
    result = connection.workspace.getConfiguration({
      scopeUri: resource,
      section: 'languageServerExample'
    });
    documentSettings.set(resource, result);
  }
  return result;
}

今行う必要があるのは、サーバー側の構成変更をリッスンし、設定が変更された場合は、開いているテキストドキュメントを再検証することだけです。ドキュメント変更イベント処理の検証ロジックを再利用できるように、コードを validateTextDocument 関数に抽出し、maxNumberOfProblems 変数を尊重するようにコードを変更します。

async function validateTextDocument(textDocument: TextDocument): Promise<void> {
  // In this simple example we get the settings for every validate run.
  let settings = await getDocumentSettings(textDocument.uri);

  // The validator creates diagnostics for all uppercase words length 2 and more
  let text = textDocument.getText();
  let pattern = /\b[A-Z]{2,}\b/g;
  let m: RegExpExecArray | null;

  let problems = 0;
  let diagnostics: Diagnostic[] = [];
  while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
    problems++;
    let diagnostic: Diagnostic = {
      severity: DiagnosticSeverity.Warning,
      range: {
        start: textDocument.positionAt(m.index),
        end: textDocument.positionAt(m.index + m[0].length)
      },
      message: `${m[0]} is all uppercase.`,
      source: 'ex'
    };
    if (hasDiagnosticRelatedInformationCapability) {
      diagnostic.relatedInformation = [
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Spelling matters'
        },
        {
          location: {
            uri: textDocument.uri,
            range: Object.assign({}, diagnostic.range)
          },
          message: 'Particularly for names'
        }
      ];
    }
    diagnostics.push(diagnostic);
  }

  // Send the computed diagnostics to VS Code.
  connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

構成変更の処理は、接続に構成変更の通知ハンドラーを追加することによって行われます。対応するコードは次のようになります。

connection.onDidChangeConfiguration(change => {
  if (hasConfigurationCapability) {
    // Reset all cached document settings
    documentSettings.clear();
  } else {
    globalSettings = <ExampleSettings>(
      (change.settings.languageServerExample || defaultSettings)
    );
  }

  // Revalidate all open text documents
  documents.all().forEach(validateTextDocument);
});

クライアントを再度起動し、設定を最大 1 つの問題を報告するように変更すると、次の検証が行われます。

Maximum One Problem

追加の言語機能の追加

Language Server が通常実装する最初の興味深い機能は、ドキュメントの検証です。その意味では、リンターでさえ Language Server としてカウントされ、VS Code では通常、リンターは Language Server として実装されます (例については、eslint および jshint を参照してください)。ただし、Language Server にはそれ以上の機能があります。コード補完、すべての参照の検索、または定義へ移動を提供できます。以下のサンプルコードは、サーバーにコード補完を追加します。 'TypeScript' と 'JavaScript' の 2 つの単語を提案します。

// This handler provides the initial list of the completion items.
connection.onCompletion(
  (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
    // The pass parameter contains the position of the text document in
    // which code complete got requested. For the example we ignore this
    // info and always provide the same completion items.
    return [
      {
        label: 'TypeScript',
        kind: CompletionItemKind.Text,
        data: 1
      },
      {
        label: 'JavaScript',
        kind: CompletionItemKind.Text,
        data: 2
      }
    ];
  }
);

// This handler resolves additional information for the item selected in
// the completion list.
connection.onCompletionResolve(
  (item: CompletionItem): CompletionItem => {
    if (item.data === 1) {
      item.detail = 'TypeScript details';
      item.documentation = 'TypeScript documentation';
    } else if (item.data === 2) {
      item.detail = 'JavaScript details';
      item.documentation = 'JavaScript documentation';
    }
    return item;
  }
);

data フィールドは、解決ハンドラーで補完項目を一意に識別するために使用されます。 data プロパティは、プロトコルに対して透過的です。基盤となるメッセージパッシングプロトコルは JSON ベースであるため、data フィールドには JSON との間でシリアル化可能なデータのみを保持する必要があります。

不足しているのは、サーバーがコード補完要求をサポートしていることを VS Code に伝えることだけです。これを行うには、初期化ハンドラーで対応する機能をフラグを立てます。

connection.onInitialize((params): InitializeResult => {
    ...
    return {
        capabilities: {
            ...
            // Tell the client that the server supports code completion
            completionProvider: {
                resolveProvider: true
            }
        }
    };
});

以下のスクリーンショットは、プレーンテキストファイルで実行されている補完されたコードを示しています。

Code Complete

Language Server のテスト

高品質の Language Server を作成するには、その機能を網羅する優れたテストスイートを構築する必要があります。 Language Server をテストするには、2 つの一般的な方法があります。

  • ユニットテスト: これは、Language Server に送信されるすべての情報をモックアップすることにより、Language Server の特定の機能をテストする場合に役立ちます。 VS Code の HTML / CSS / JSON Language Server は、このテストアプローチを採用しています。 LSP npm モジュールもこのアプローチを使用しています。 npm プロトコルモジュールを使用して記述されたユニットテストについては、こちらを参照してください。
  • エンドツーエンドテスト: これは、VS Code 拡張機能テストに似ています。このアプローチの利点は、ワークスペースを備えた VS Code インスタンスをインスタンス化し、ファイルを開き、Language Client / Server をアクティブ化し、VS Code コマンドを実行してテストを実行することです。このアプローチは、モックが困難または不可能なファイル、設定、または依存関係 (node_modules など) がある場合に優れています。人気のある Python 拡張機能は、このテストアプローチを採用しています。

ユニットテストは、任意のテストフレームワークで実行できます。ここでは、Language Server Extension のエンドツーエンドテストを実行する方法について説明します。

.vscode/launch.json を開き、E2E テストターゲットを見つけることができます。

{
  "name": "Language Server E2E Test",
  "type": "extensionHost",
  "request": "launch",
  "runtimeExecutable": "${execPath}",
  "args": [
    "--extensionDevelopmentPath=${workspaceRoot}",
    "--extensionTestsPath=${workspaceRoot}/client/out/test/index",
    "${workspaceRoot}/client/testFixture"
  ],
  "outFiles": ["${workspaceRoot}/client/out/test/**/*.js"]
}

このデバッグターゲットを実行すると、client/testFixture をアクティブなワークスペースとして VS Code インスタンスが起動します。 VS Code は、client/src/test のすべてのテストを実行します。デバッグのヒントとして、client/src/test の TypeScript ファイルにブレークポイントを設定すると、ヒットします。

completion.test.ts ファイルを見てみましょう。

import * as vscode from 'vscode';
import * as assert from 'assert';
import { getDocUri, activate } from './helper';

suite('Should do completion', () => {
  const docUri = getDocUri('completion.txt');

  test('Completes JS/TS in txt file', async () => {
    await testCompletion(docUri, new vscode.Position(0, 0), {
      items: [
        { label: 'JavaScript', kind: vscode.CompletionItemKind.Text },
        { label: 'TypeScript', kind: vscode.CompletionItemKind.Text }
      ]
    });
  });
});

async function testCompletion(
  docUri: vscode.Uri,
  position: vscode.Position,
  expectedCompletionList: vscode.CompletionList
) {
  await activate(docUri);

  // Executing the command `vscode.executeCompletionItemProvider` to simulate triggering completion
  const actualCompletionList = (await vscode.commands.executeCommand(
    'vscode.executeCompletionItemProvider',
    docUri,
    position
  )) as vscode.CompletionList;

  assert.ok(actualCompletionList.items.length >= 2);
  expectedCompletionList.items.forEach((expectedItem, i) => {
    const actualItem = actualCompletionList.items[i];
    assert.equal(actualItem.label, expectedItem.label);
    assert.equal(actualItem.kind, expectedItem.kind);
  });
}

このテストでは、

  • 拡張機能をアクティブ化します。
  • URI と位置を指定してコマンド vscode.executeCompletionItemProvider を実行し、補完トリガーをシミュレートします。
  • 返された補完項目を、予想される補完項目に対してアサートします。

activate(docURI) 関数を少し深く掘り下げてみましょう。これは client/src/test/helper.ts で定義されています。

import * as vscode from 'vscode';
import * as path from 'path';

export let doc: vscode.TextDocument;
export let editor: vscode.TextEditor;
export let documentEol: string;
export let platformEol: string;

/**
 * Activates the vscode.lsp-sample extension
 */
export async function activate(docUri: vscode.Uri) {
  // The extensionId is `publisher.name` from package.json
  const ext = vscode.extensions.getExtension('vscode-samples.lsp-sample')!;
  await ext.activate();
  try {
    doc = await vscode.workspace.openTextDocument(docUri);
    editor = await vscode.window.showTextDocument(doc);
    await sleep(2000); // Wait for server activation
  } catch (e) {
    console.error(e);
  }
}

async function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

アクティベーションパートでは、

  • package.json で定義されているように、{publisher.name}.{extensionId} を使用して拡張機能を取得します。
  • 指定されたドキュメントを開き、アクティブなテキストエディターに表示します。
  • 2 秒間スリープして、Language Server がインスタンス化されていることを確認します。

準備後、各言語機能に対応するVS Code コマンドを実行し、返された結果に対してアサートできます。

実装したばかりの診断機能を網羅するテストがもう 1 つあります。 client/src/test/diagnostics.test.ts で確認してください。

高度なトピック

ここまで、このガイドでは以下について説明しました。

  • Language Server と Language Server Protocol の簡単な概要。
  • VS Code における Language Server 拡張機能のアーキテクチャ
  • lsp-sample 拡張機能、およびその開発/デバッグ/検査/テスト方法。

このガイドに収まりきらなかった高度なトピックがいくつかあります。 Language Server の開発をさらに学習するためのこれらのリソースへのリンクを含めます。

追加の Language Server 機能

次の言語機能は、コード補完とともに Language Server で現在サポートされています。

  • ドキュメントの強調表示: テキストドキュメント内のすべての '等しい' 記号を強調表示します。
  • ホバー: テキストドキュメントで選択された記号のホバー情報を提供します。
  • シグネチャヘルプ: テキストドキュメントで選択された記号のシグネチャヘルプを提供します。
  • 定義へ移動: テキストドキュメントで選択された記号の定義へ移動のサポートを提供します。
  • 型定義へ移動: テキストドキュメントで選択された記号の型/インターフェイス定義へ移動のサポートを提供します。
  • 実装へ移動: テキストドキュメントで選択された記号の実装定義へ移動のサポートを提供します。
  • 参照の検索: テキストドキュメントで選択された記号のプロジェクト全体のすべての参照を検索します。
  • ドキュメントシンボルの一覧表示: テキストドキュメントで定義されているすべての記号を一覧表示します。
  • ワークスペースシンボルの一覧表示: プロジェクト全体のすべての記号を一覧表示します。
  • コードアクション: 特定のテキストドキュメントと範囲に対して実行するコマンド (通常は整形/リファクタリング) を計算します。
  • CodeLens: 特定のテキストドキュメントの CodeLens 統計を計算します。
  • ドキュメントの書式設定: これには、ドキュメント全体の書式設定、ドキュメント範囲、および入力時の書式設定が含まれます。
  • 名前変更: 記号のプロジェクト全体の名前変更。
  • ドキュメントリンク: ドキュメント内のリンクを計算して解決します。
  • ドキュメントの色: ドキュメント内の色を計算して解決し、エディターにカラーピッカーを提供します。

プログラムによる言語機能のトピックでは、上記の各言語機能について説明し、言語サーバープロトコルを介して、または拡張機能から直接拡張機能 API を使用してそれらを実装する方法に関するガイダンスを提供します。

テキストドキュメントの増分同期

この例では、vscode-languageserver モジュールによって提供される単純なテキストドキュメントマネージャーを使用して、VS Code と Language Server 間でドキュメントを同期します。

これには 2 つの欠点があります。

  • テキストドキュメントの内容全体がサーバーに繰り返し送信されるため、大量のデータが転送されます。
  • 既存の言語ライブラリを使用する場合、そのようなライブラリは通常、不要な解析と抽象構文木の作成を回避するために、ドキュメントの増分更新をサポートしています。

したがって、プロトコルはドキュメントの増分同期もサポートしています。

ドキュメントの増分同期を利用するには、サーバーは 3 つの通知ハンドラーをインストールする必要があります。

  • onDidOpenTextDocument: VS Code でテキストドキュメントが開かれたときに呼び出されます。
  • onDidChangeTextDocument: VS Code でテキストドキュメントの内容が変更されたときに呼び出されます。
  • onDidCloseTextDocument: VS Code でテキストドキュメントが閉じられたときに呼び出されます。

以下は、これらの通知ハンドラーを接続にフックする方法と、初期化時に適切な機能を返す方法を示すコードスニペットです。

connection.onInitialize((params): InitializeResult => {
    ...
    return {
        capabilities: {
            // Enable incremental document sync
            textDocumentSync: TextDocumentSyncKind.Incremental,
            ...
        }
    };
});

connection.onDidOpenTextDocument((params) => {
    // A text document was opened in VS Code.
    // params.uri uniquely identifies the document. For documents stored on disk, this is a file URI.
    // params.text the initial full content of the document.
});

connection.onDidChangeTextDocument((params) => {
    // The content of a text document has change in VS Code.
    // params.uri uniquely identifies the document.
    // params.contentChanges describe the content changes to the document.
});

connection.onDidCloseTextDocument((params) => {
    // A text document was closed in VS Code.
    // params.uri uniquely identifies the document.
});

/*
Make the text document manager listen on the connection
for open, change and close text document events.

Comment out this line to allow `connection.onDidOpenTextDocument`,
`connection.onDidChangeTextDocument`, and `connection.onDidCloseTextDocument` to handle the events
*/
// documents.listen(connection);

VS Code API を直接使用して言語機能を実装する

Language Server には多くの利点がありますが、VS Code の編集機能を拡張するための唯一のオプションではありません。ドキュメントのタイプにいくつかの単純な言語機能を追加する場合は、オプションとして vscode.languages.register[LANGUAGE_FEATURE]Provider を使用することを検討してください。

プレーンテキストファイルの補完としていくつかのスニペットを追加するために vscode.languages.registerCompletionItemProvider を使用する completions-sample を次に示します。

VS Code API の使用法を示すその他のサンプルは、https://github.com/microsoft/vscode-extension-samples にあります。

Language Server のエラー耐性パーサー

ほとんどの場合、エディター内のコードは不完全で構文的に正しくありませんが、開発者はそれでもオートコンプリートやその他の言語機能が機能することを期待します。したがって、Language Server にはエラー耐性パーサーが必要です。パーサーは、部分的に完成したコードから意味のある AST を生成し、Language Server は AST に基づいて言語機能を提供します。

VS Code での PHP サポートを改善していたとき、公式の PHP パーサーはエラー耐性がなく、Language Server で直接再利用できないことに気付きました。したがって、Microsoft/tolerant-php-parser に取り組み、エラー耐性パーサーを実装する必要がある Language Server 作成者に役立つ可能性のある詳細なメモを残しました。

よくある質問

サーバーにアタッチしようとすると、「ランタイムプロセスに接続できません (5000 ミリ秒後にタイムアウト)」というエラーが表示されます。

デバッガーをアタッチしようとしたときにサーバーが実行されていない場合、このタイムアウトエラーが表示されます。クライアントは Language Server を起動するため、実行中のサーバーを起動するためにクライアントを起動していることを確認してください。クライアントのブレークポイントがサーバーの起動を妨げている場合は、クライアントのブレークポイントを無効にする必要もあります。

このガイドとLSP 仕様を読みましたが、まだ解決されていない質問があります。どこでヘルプを入手できますか?

https://github.com/microsoft/language-server-protocol で問題をオープンしてください。