言語サーバー拡張機能ガイド
プログラムによる言語機能のトピックで見たように、languages.* API を直接使用して言語機能を実装することは可能です。しかし、言語サーバー拡張(Language Server Extension)は、そのような言語サポートを実装するための代替手段を提供します。
本トピックでは
- 言語サーバー拡張の利点について説明します。
Microsoft/vscode-languageserver-nodeライブラリを使用して言語サーバーを構築する方法を説明します。また、lsp-sample のコードに直接飛ぶこともできます。
なぜ言語サーバーなのか?
言語サーバーは、多くのプログラミング言語の編集エクスペリエンスを強化する、特別な種類の Visual Studio Code 拡張機能です。言語サーバーを使用すると、オートコンプリート、エラーチェック(診断)、定義への移動など、VS Code でサポートされている他の多くの言語機能を実装できます。
しかし、VS Code で言語機能のサポートを実装する際、私たちは3つの共通した問題に直面しました。
第一に、言語サーバーは通常、それぞれのネイティブプログラミング言語で実装されており、Node.js ランタイムを持つ VS Code と統合する上で課題が生じます。
さらに、言語機能はリソースを集中的に消費する可能性があります。例えば、ファイルを正確に検証するには、言語サーバーは大量のファイルを解析し、それらの抽象構文木(AST)を構築し、静的プログラム解析を実行する必要があります。これらの操作は CPU とメモリを著しく消費する可能性があるため、VS Code のパフォーマンスが影響を受けないようにする必要があります。
最後に、複数の言語ツールを複数のコードエディタと統合するには多大な労力がかかる可能性があります。言語ツール開発者の視点からは、異なる API を持つコードエディタに適応する必要があります。コードエディタの視点からは、言語ツール側に統一された API を期待できません。これにより、M 個の言語のサポートを N 個のコードエディタ向けに実装することは M * N の作業となってしまいます。
これらの問題を解決するために、マイクロソフトは Language Server Protocol (LSP) を規定しました。これは、言語ツールとコードエディタ間の通信を標準化するものです。これにより、言語サーバーはあらゆる言語で実装でき、独自のプロセスで実行されるためパフォーマンス上の負荷を回避できます。なぜなら、言語サーバーは Language Server Protocol を介してコードエディタと通信するからです。さらに、LSP に準拠したあらゆる言語ツールは複数の LSP 準拠コードエディタと統合でき、LSP 準拠のコードエディタは複数の LSP 準拠言語ツールを簡単に導入できます。LSP は、言語ツールプロバイダーとコードエディタベンダー双方にとっての勝利なのです!

本ガイドでは、以下のことを行います。
- 提供されている Node SDK を使用して、VS Code で言語サーバー拡張を構築する方法を説明します。
- 言語サーバー拡張の実行、デバッグ、ログ出力、テストの方法を説明します。
- 言語サーバーに関する高度なトピックを紹介します。
言語サーバーの実装
概要
VS Code において、言語サーバーは2つの部分で構成されます。
- Language Client(言語クライアント): JavaScript / TypeScript で記述された通常の VS Code 拡張機能です。この拡張機能は、すべての VS Code 名前空間 API にアクセスできます。
- Language Server(言語サーバー): 別のプロセスで実行される言語解析ツールです。
前述の通り、言語サーバーを別のプロセスで実行することには2つの利点があります。
- Language Server Protocol に従って言語クライアントと通信できる限り、解析ツールはどんな言語でも実装可能です。
- 言語解析ツールは多くの場合、CPU とメモリの使用負荷が高いため、別のプロセスで実行することでパフォーマンスへの影響を回避できます。
以下は、2つの言語サーバー拡張を実行している VS Code の図です。HTML 言語クライアントと PHP 言語クライアントは、TypeScript で記述された通常の VS Code 拡張機能です。それぞれが対応する言語サーバーをインスタンス化し、LSP を介して通信します。PHP 言語サーバーは PHP で記述されていますが、それでも LSP を介して PHP 言語クライアントと通信できます。

本ガイドでは、私たちの Node SDK を使用して言語クライアント/サーバーを構築する方法を教えます。このドキュメントの以降の部分は、あなたが VS Code の 拡張機能 API に精通していることを前提としています。
LSP サンプル - プレーンテキストファイル用のシンプルな言語サーバー
プレーンテキストファイルのためのオートコンプリートと診断を実装する、シンプルな言語サーバー拡張を構築しましょう。また、クライアントとサーバー間での設定の同期についても扱います。
コードに直接飛び込みたい場合は、以下を参照してください。
- lsp-sample: 本ガイドのための詳細なドキュメント付きソースコード。
- lsp-multi-server-sample: lsp-sample の高度なバージョンで、VS Code のマルチルートワークスペース機能をサポートするために、ワークスペースフォルダごとに異なるサーバーインスタンスを起動します。
リポジトリ 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' の説明
まずは言語クライアントの機能を記述している /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 より前のバージョンと互換性がある必要がある場合は、
/package.jsonのactivationEventsフィールドにonLanguage:plaintextを宣言して、プレーンテキストファイル(例:.txt拡張子のファイル)が開かれた直後に拡張機能をアクティブ化するよう VS Code に伝える必要があります。"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 | undefined;
export async 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
await client.start();
}
export async function deactivate() {
await client?.dispose();
client = undefined;
}
'Language Server' の説明
注: GitHub リポジトリからクローンされた 'Server' 実装には、最終的なウォークスルーの実装が含まれています。ウォークスルーに従うために、新しい
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();
シンプルな検証機能の追加
サーバーにドキュメント検証を追加するには、テキストドキュメントの内容が変更されるたびに呼び出されるリスナーをテキストドキュメントマネージャーに追加します。ドキュメントをいつ検証するのが最適かは、サーバー側で決定します。この実装例では、サーバーはプレーンテキストドキュメントを検証し、すべて大文字(ALL CAPS)の単語が出現する箇所をすべてフラグ付けします。対応するコードスニペットは以下の通りです。
// 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 });
});
診断(Diagnostics)のヒントとコツ
- 開始位置と終了位置が同じ場合、VS Code はその位置の単語に波線を表示します。
- 行の終わりまで波線を引きたい場合は、終了位置の文字を
Number.MAX_VALUEに設定します。
言語サーバーを実行するには、以下の手順に従います。
- ⇧⌘B (Windows, Linux Ctrl+Shift+B) を押してビルドタスクを開始します。このタスクはクライアントとサーバーの両方をコンパイルします。
- 実行 ビューを開き、Launch Client 起動設定を選択して、デバッグの開始 ボタンを押すと、拡張機能コードを実行する追加の Extension Development Host インスタンスの 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.
Extension Development Host インスタンスは以下のようになります。

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

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

言語サーバーのログ出力サポート
vscode-languageclient を使用してクライアントを実装している場合、[langId].trace.server 設定を指定できます。これにより、言語クライアント/サーバー間の通信を言語クライアントの name のチャンネルにログ出力するようクライアントに指示できます。
lsp-sample の場合は、設定 "languageServerExample.trace.server": "verbose" を行います。次に "Language Server Example" チャンネルを確認してください。ログが表示されるはずです。

サーバーでの設定値の使用
拡張機能のクライアント部分を作成する際に、報告される問題の最大数を制御する設定を定義済みです。また、クライアントからこれらの設定を読み取るサーバー側のコードも記述しました。
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 に変更すると、以下の検証結果が得られます。

追加の言語機能の追加
言語サーバーが最初に実装する興味深い機能は、通常ドキュメントの検証です。その意味で、リンターでさえ言語サーバーとしてカウントされますし、VS Code におけるリンターは通常言語サーバーとして実装されています(例として eslint や jshint を参照してください)。しかし、言語サーバーには他にもできることがあります。コード補完、全参照の検索、定義への移動を提供できます。以下のサンプルコードは、サーバーにコード補完を追加します。'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
}
}
};
});
以下のスクリーンショットは、プレーンテキストファイルで実行されている完成したコードを示しています。

言語サーバーのテスト
高品質な言語サーバーを作成するには、その機能をカバーする優れたテストスイートを構築する必要があります。言語サーバーをテストする一般的な方法は2つあります。
- 単体テスト(Unit Test): これは、言語サーバーに送信されるすべての情報をモック化して、言語サーバー内の特定の機能をテストしたい場合に便利です。VS Code の HTML / CSS / JSON 言語サーバーはこのアプローチを採用しています。LSP npm モジュールもこのアプローチを使用しています。npm プロトコルモジュールを使用して記述された単体テストについては、こちらを参照してください。
- エンドツーエンドテスト(E2E Test): これは 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 内のすべてのテストを実行します。デバッグのコツとして、TypeScript ファイル内の client/src/test にブレークポイントを設定すれば、そこで停止します。
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 コマンドを実行し、返された結果に対してアサートします。
先ほど実装した診断機能についてもカバーするテストがもう一つあります。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 に取り組み、エラー耐性のあるパーサーを実装する必要がある言語サーバー作成者の役に立つ可能性のある詳細なメモを残しました。
よくある質問
サーバーにアタッチしようとすると "cannot connect to runtime process (timeout after 5000 ms)" と表示されます。
デバッガーをアタッチしようとしたときにサーバーが実行されていない場合、このタイムアウトエラーが表示されます。クライアントが言語サーバーを起動するため、サーバーが実行されるように必ずクライアントを起動してください。また、クライアントのブレークポイントがサーバーの起動を妨げている場合は、それを無効にする必要があるかもしれません。
このガイドと LSP 仕様を読みましたが、まだ解決していない質問があります。どこで助けを得られますか?
https://github.com/microsoft/language-server-protocol に Issue を開いてください。