语言服务器扩展指南
正如你在编程语言功能主题中看到的,可以直接使用 languages.*
API 来实现语言功能。然而,语言服务器扩展提供了一种实现此类语言支持的替代方法。
本主题
- 解释语言服务器扩展的优势。
- 指导你使用
Microsoft/vscode-languageserver-node
库构建语言服务器。你也可以直接跳转到 lsp-sample 中的代码。
为什么选择语言服务器?
语言服务器是一种特殊的 Visual Studio Code 扩展,它为许多编程语言提供了强大的编辑体验。通过语言服务器,你可以实现自动完成、错误检查(诊断)、跳转到定义以及 VS Code 支持的许多其他语言功能。
然而,在 VS Code 中实现语言功能支持时,我们发现了三个常见问题
首先,语言服务器通常使用其原生编程语言实现,这给与具有 Node.js 运行时的 VS Code 集成带来了挑战。
此外,语言功能可能会占用大量资源。例如,要正确验证文件,语言服务器需要解析大量文件,为它们构建抽象语法树并执行静态程序分析。这些操作可能会导致大量的 CPU 和内存使用,我们需要确保 VS Code 的性能不受影响。
最后,将多种语言工具与多种代码编辑器集成可能需要大量的精力。从语言工具的角度来看,它们需要适应具有不同 API 的代码编辑器。从代码编辑器的角度来看,它们无法期望语言工具提供任何统一的 API。这使得在 N 个代码编辑器中实现 M 种语言的语言支持成为 M * N 的工作量。
为了解决这些问题,Microsoft 规范了 语言服务器协议,它标准化了语言工具和代码编辑器之间的通信。这样,语言服务器就可以用任何语言实现,并在自己的进程中运行,以避免性能开销,因为它们通过语言服务器协议与代码编辑器通信。此外,任何符合 LSP 的语言工具都可以与多个符合 LSP 的代码编辑器集成,任何符合 LSP 的代码编辑器也可以轻松地使用多个符合 LSP 的语言工具。LSP 对语言工具提供商和代码编辑器供应商来说都是一个胜利!
在本指南中,我们将
- 解释如何使用提供的 Node SDK 在 VS Code 中构建语言服务器扩展。
- 解释如何运行、调试、记录和测试语言服务器扩展。
- 为你指出一些关于语言服务器的高级主题。
实现语言服务器
概述
在 VS Code 中,语言服务器包含两部分
- 语言客户端:用 JavaScript / TypeScript 编写的普通 VS Code 扩展。此扩展可以访问所有 VS Code Namespace API。
- 语言服务器:在单独进程中运行的语言分析工具。
如上所述,在单独进程中运行语言服务器有两个好处
- 只要分析工具能够遵循语言服务器协议与语言客户端通信,就可以用任何语言实现。
- 由于语言分析工具通常会大量占用 CPU 和内存,因此在单独进程中运行它们可以避免性能开销。
下面是 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
文件,它描述了语言客户端的功能。其中有两个有趣的章节
首先,查看 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 版本之前兼容,你必须在
/package.json
文件的activationEvents
字段中声明onLanguage:plaintext
,以告诉 VS Code 在纯文本文件打开时立即激活扩展(例如扩展名为.txt
的文件)"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 namespace API。
下面是相应的 extension.ts
文件内容,它是 lsp-sample 扩展的入口点
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.
然后扩展开发宿主实例将如下所示
调试客户端和服务器
调试客户端代码就像调试普通扩展一样简单。在客户端代码中设置断点,然后按 F5 调试扩展。
由于服务器是由在扩展(客户端)中运行的 LanguageClient
启动的,我们需要将调试器附加到正在运行的服务器。为此,切换到“运行和调试”视图,选择“Attach to Server”启动配置,然后按 F5。这将把调试器附加到服务器。
语言服务器的日志支持
如果你使用 vscode-languageclient
实现客户端,可以指定一个设置 [langId].trace.server
,该设置指示客户端将语言客户端/服务器之间的通信日志记录到语言客户端名称的通道中。
对于 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 个问题,结果会得到以下验证
添加其他语言功能
语言服务器通常实现的第一个有趣功能是文档验证。从这个意义上说,即使是 linter 也算作语言服务器,在 VS Code 中 linter 通常作为语言服务器实现(参见 eslint 和 jshint 示例)。但语言服务器的功能远不止于此。它们可以提供代码完成、查找所有引用或跳转到定义等功能。下面的示例代码为服务器添加了代码完成功能。它建议了两个词:“TypeScript”和“JavaScript”。
// 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 和从 JSON 反序列化的数据。
唯一缺少的就是告诉 VS Code 服务器支持代码完成请求。为此,在 initialize
处理程序中标记相应的能力
connection.onInitialize((params): InitializeResult => {
...
return {
capabilities: {
...
// Tell the client that the server supports code completion
completionProvider: {
resolveProvider: true
}
}
};
});
下面的截图显示了在纯文本文件上运行的完成代码
测试语言服务器
要创建高质量的语言服务器,我们需要构建一个覆盖其功能的良好测试套件。测试语言服务器有两种常见方法
- 单元测试:如果你想通过模拟发送到语言服务器的所有信息来测试其特定功能,这种方法很有用。VS Code 的 HTML / CSS / JSON 语言服务器采用此方法进行测试。LSP npm 模块也采用此方法。请参阅此处查看一些使用 npm protocol module 编写的单元测试。
- 端到端测试:这类似于 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);
});
}
在此测试中,我们
- 激活扩展。
- 运行命令
vscode.executeCompletionItemProvider
,并带上 URI 和位置参数,以模拟补全触发。 - 断言返回的补全项与我们预期的补全项一致。
让我们深入了解一下 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));
}
在激活部分,我们
- 使用
{publisher.name}.{extensionId}
获取扩展,这在package.json
中定义。 - 打开指定文档,并在活动文本编辑器中显示。
- 暂停 2 秒,以确保语言服务器已实例化。
准备好后,我们可以运行与每个语言功能对应的 VS Code 命令,并断言返回结果。
还有一个测试涵盖了我们刚刚实现的诊断功能。请查看 client/src/test/diagnostics.test.ts
。
高级主题
到目前为止,本指南涵盖了
- 语言服务器和语言服务器协议的简要概述。
- VS Code 中语言服务器扩展的架构
- lsp-sample 扩展,以及如何开发/调试/检查/测试它。
本指南无法涵盖一些更高级的主题。我们将提供这些资源的链接,以便进一步学习语言服务器开发。
其他语言服务器功能
语言服务器目前支持以下语言功能以及代码补全功能
- 文档高亮:高亮文本文档中所有“相等”的符号。
- 悬停:为文本文档中选定的符号提供悬停信息。
- 签名帮助:为文本文档中选定的符号提供签名帮助。
- 跳转到定义:为文本文档中选定的符号提供跳转到定义支持。
- 跳转到类型定义:为文本文档中选定的符号提供跳转到类型/接口定义支持。
- 跳转到实现:为文本文档中选定的符号提供跳转到实现定义支持。
- 查找引用:查找文本文档中选定的符号的所有项目范围引用。
- 列出文档符号:列出文本文档中定义的所有符号。
- 列出工作区符号:列出所有项目范围符号。
- 代码操作:计算给定文本文档和范围要运行的命令(通常是美化/重构)。
- CodeLens:计算给定文本文档的 CodeLens 统计信息。
- 文档格式化:包括整个文档、文档范围和键入时格式化。
- 重命名:项目范围的符号重命名。
- 文档链接:计算并解析文档内部链接。
- 文档颜色:计算并解析文档内部颜色,以便在编辑器中提供颜色选择器。
编程语言功能主题描述了上述每项语言功能,并提供了如何通过语言服务器协议或直接使用扩展中的可扩展性 API 实现它们的指导。
增量文本文档同步
示例使用了 vscode-languageserver
模块提供的简单文本文档管理器来同步 VS Code 和语言服务器之间的文档。
这有两个缺点
- 由于文本文档的整个内容会重复发送到服务器,因此传输的数据量很大。
- 如果使用了现有的语言库,此类库通常支持增量文档更新,以避免不必要的解析和抽象语法树创建。
因此,协议也支持增量文档同步。
要利用增量文档同步,服务器需要安装三个通知处理程序
onDidOpenTextDocument
:在 VS Code 中打开文本文档时调用。onDidChangeTextDocument
:在 VS Code 中文本文档内容更改时调用。onDidCloseTextDocument
:在 VS Code 中关闭文本文档时调用。
下面是一个代码片段,说明了如何将这些通知处理程序挂接到连接上以及如何在 initialize
时返回正确的功能
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
来为纯文本文件添加一些代码片段作为补全。
可以在 https://github.com/microsoft/vscode-extension-samples 找到更多展示 VS Code API 用法的示例。
语言服务器的容错解析器
大多数情况下,编辑器中的代码是不完整且存在语法错误的,但开发人员仍然希望自动完成和其他语言功能能够正常工作。因此,容错解析器对于语言服务器来说是必需的:解析器从部分完整的代码生成有意义的 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 提交问题。