言語サーバー拡張機能ガイド
プログラム言語機能のトピックで見てきたように、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を期待することはできません。これにより、M
言語のサポートをN
コードエディターで実装する作業はM * N
になります。
これらの問題を解決するために、Microsoft はLanguage Server Protocol を規定し、言語ツールとコードエディター間の通信を標準化しました。これにより、言語サーバーは任意の言語で実装でき、独自のプロセスで実行してパフォーマンスコストを回避できます。これは、言語サーバープロトコルを介してコードエディターと通信するためです。さらに、LSP 準拠の言語ツールは複数のLSP準拠コードエディターと統合でき、LSP準拠コードエディターは複数のLSP準拠言語ツールを簡単に利用できます。LSP は、言語ツールプロバイダーとコードエディターベンダーの両方にとってメリットがあります!
このガイドでは、以下のことを説明します。
- 提供されているNode SDK を使用して、VS Code で言語サーバー拡張機能を構築する方法を説明します。
- 言語サーバー拡張機能を実行、デバッグ、ログ、テストする方法を説明します。
- 言語サーバーに関するいくつかの高度なトピックを紹介します。
言語サーバーの実装
概要
VS Code において、言語サーバーは2つの部分から構成されます。
- 言語クライアント: JavaScript / TypeScript で書かれた通常の VS Code 拡張機能です。この拡張機能は、すべてのVS Code 名前空間 API にアクセスできます。
- 言語サーバー: 別のプロセスで実行される言語解析ツールです。
上記で簡単に述べたように、言語サーバーを別のプロセスで実行することには2つの利点があります。
- 解析ツールは、言語サーバープロトコルに従って言語クライアントと通信できる限り、任意の言語で実装できます。
- 言語解析ツールは 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
「言語クライアント」の説明
まず、言語クライアントの機能を記述する /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."
}
}
}
このセクションは、VS Code にconfiguration
設定を提供します。この例では、これらの設定が起動時および設定が変更されるたびに言語サーバーにどのように送信されるかを説明します。
注意: 拡張機能が VS Code バージョン 1.74.0 より前のバージョンと互換性がある場合、VS Code にプレーンテキストファイルが開かれたらすぐに拡張機能をアクティブ化するように指示するために (例: 拡張子
.txt
のファイルなど)、/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) を押してビルドタスクを開始します。このタスクは、クライアントとサーバーの両方をコンパイルします。
- 実行ビューを開き、クライアント起動の起動構成を選択し、デバッグ開始ボタンを押して、拡張機能コードを実行する追加の 拡張機能開発ホスト インスタンスの 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.
拡張機能開発ホスト インスタンスは次のようになります。
クライアントとサーバーの両方のデバッグ
クライアントコードのデバッグは、通常の拡張機能をデバッグするのと同じくらい簡単です。クライアントコードにブレークポイントを設定し、F5 を押して拡張機能をデバッグします。
サーバーは拡張機能 (クライアント) で実行されている LanguageClient
によって起動されるため、実行中のサーバーにデバッガーをアタッチする必要があります。これを行うには、実行とデバッグビューに切り替えて、起動構成サーバーにアタッチを選択し、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つあります。
- 単体テスト: これは、言語サーバーに送信されるすべての情報をモックアップすることで、特定の機能をテストしたい場合に役立ちます。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 コマンドを実行し、返された結果に対してアサートできます。
実装したばかりの診断機能をカバーする別のテストがあります。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
をオプションとして検討してください。
completions-sample
は、vscode.languages.registerCompletionItemProvider
を使用して、プレーンテキストファイルにいくつかのスニペットを補完として追加する例です。
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 に問題をオープンしてください。