VS Code でを試す!

言語サーバー拡張機能ガイド

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

このトピックでは

  • 言語サーバー拡張機能の利点について説明します。
  • Microsoft/vscode-languageserver-nodeライブラリを使用して言語サーバーを構築する方法を説明します。lsp-sampleのコードに直接アクセスすることもできます。

なぜ言語サーバーか?

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

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

第一に、言語サーバーは通常、ネイティブのプログラミング言語で実装されており、Node.jsランタイムを持つVS Codeとの統合に課題を提示します。

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

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

これらの問題を解決するために、Microsoftは言語ツールとコードエディター間の通信を標準化する言語サーバープロトコルを策定しました。これにより、言語サーバーはどの言語でも実装でき、言語サーバープロトコルを介してコードエディターと通信するため、パフォーマンスコストを回避するために独自のプロセスで実行できます。さらに、LSPに準拠する言語ツールは複数のLSP準拠コードエディターと統合でき、LSP準拠コードエディターは複数のLSP準拠言語ツールを簡単に採用できます。LSPは、言語ツールプロバイダーとコードエディターベンダーの両方にとって有利です!

LSP Languages and Editors

このガイドでは、以下のことを説明します。

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

言語サーバーの実装

概要

VS Codeでは、言語サーバーは2つの部分から構成されます。

  • 言語クライアント: JavaScript / TypeScriptで記述された通常のVS Code拡張機能です。この拡張機能は、すべてのVS Code名前空間APIにアクセスできます。
  • 言語サーバー: 別個のプロセスで実行される言語解析ツールです。

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

  • 解析ツールは、言語サーバープロトコルに従って言語クライアントと通信できる限り、どの言語でも実装できます。
  • 言語解析ツールはCPUとメモリの使用量が大きいことが多いため、別のプロセスで実行することでパフォーマンスコストを回避できます。

VS Codeで2つの言語サーバー拡張機能を実行している例を示します。HTML言語クライアントとPHP言語クライアントは、TypeScriptで記述された通常のVS Code拡張機能です。それぞれが対応する言語サーバーをインスタンス化し、LSPを通じてそれらと通信します。PHP言語サーバーはPHPで記述されていますが、LSPを通じてPHP言語クライアントと通信できます。

LSP Illustration

このガイドでは、当社のNode SDKを使用して言語クライアント/サーバーを構築する方法を説明します。残りのドキュメントでは、VS Code 拡張機能 APIに精通していることを前提としています。

LSP サンプル - プレーンテキストファイル用のシンプルな言語サーバー

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

コードに直接アクセスしたい場合

  • lsp-sample: このガイドのための詳細なドキュメント付きソースコード。
  • lsp-multi-server-sample: マルチルートワークスペース機能をサポートするため、ワークスペースフォルダーごとに異なるサーバーインスタンスを起動する、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

「言語クライアント」について

まず、言語クライアントの機能を記述する`/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に提供します。この例では、これらの設定が起動時および設定の変更ごとに言語サーバーにどのように送信されるかを説明します。

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

"activationEvents": []

実際の言語クライアントのソースコードと対応する`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();
}

「言語サーバー」について

注: GitHubリポジトリからクローンされた「サーバー」実装には、最終的なチュートリアル実装が含まれています。チュートリアルを進めるには、新しい`server.ts`を作成するか、クローンされたバージョンの内容を修正することができます。

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

言語サーバーのソースコードは`/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`に設定してください。

言語サーバーを実行するには、以下の手順を実行します。

  • ⇧⌘B (Windows、Linux では Ctrl+Shift+B)を押してビルドタスクを開始します。このタスクはクライアントとサーバーの両方をコンパイルします。
  • 実行ビューを開き、Launch Client起動構成を選択し、デバッグ開始ボタンを押して、拡張機能コードを実行する追加の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`によって起動されるため、実行中のサーバーにデバッガーをアタッチする必要があります。そのためには、実行とデバッグビューに切り替えて、起動構成Attach to Serverを選択し、F5を押します。これにより、サーバーにデバッガーがアタッチされます。

Debugging the server

言語サーバーのログサポート

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

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

追加の言語機能の追加

言語サーバーが通常最初に実装する興味深い機能は、ドキュメントの検証です。その意味で、リンターでさえ言語サーバーと見なされ、VS Codeではリンターは通常言語サーバーとして実装されます (例についてはeslintjshintを参照してください)。しかし、言語サーバーにはそれ以上の機能があります。コード補完、すべての参照の検索、定義への移動を提供できます。以下のコード例は、サーバーにコード補完機能を追加します。これは「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`フィールドは、解決ハンドラーで補完アイテムを一意に識別するために使用されます。データプロパティはプロトコルに対して透過的です。基になるメッセージパッシングプロトコルは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

言語サーバーのテスト

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

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

ユニットテストは、選択した任意のテストフレームワークで実行できます。ここでは、言語サーバー拡張機能のエンドツーエンドテストを実行する方法を説明します。

`.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秒間待機します。

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

私たちが実装した診断機能をカバーするもう1つのテストがあります。`client/src/test/diagnostics.test.ts`で確認してください。

高度なトピック

これまでのところ、このガイドでは以下のことを説明しました。

  • 言語サーバーと言語サーバープロトコルの概要。
  • VS Codeにおける言語サーバー拡張機能のアーキテクチャ。
  • lsp-sample拡張機能と、その開発/デバッグ/調査/テスト方法。

このガイドでは説明しきれなかった、より高度なトピックがいくつかあります。言語サーバー開発のさらなる学習のために、これらのリソースへのリンクを含めます。

追加の言語サーバー機能

以下の言語機能は現在、言語サーバーでコード補完と共にサポートされています。

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

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

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

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

これには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を直接使用して言語機能を実装する

言語サーバーには多くの利点がありますが、VS Codeの編集機能を拡張する唯一の選択肢ではありません。ある種のドキュメントにシンプルな言語機能を追加したい場合は、`vscode.languages.register[LANGUAGE_FEATURE]Provider`をオプションとして検討してください。

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

VS Code APIの使用法を示すさらなるサンプルは、https://github.com/microsoft/vscode-extension-samplesで見つけることができます。

言語サーバー用エラー許容パーサー

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

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

よくある質問

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

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

このガイドとLSP仕様を読みましたが、まだ解決されていない質問があります。どこで助けを得られますか?

https://github.com/microsoft/language-server-protocolでイシューを開いてください。