例子——语言服务器

简要概述语言服务器和语言服务器协议

语言服务器是一种特殊的扩展,支持VS代码中许多编程语言的编辑体验。使用语言服务器,您可以实现VS代码中支持的自动完成、错误检查(诊断)、快速定义和许多其他语言特性。

然而,在VS代码中实现对语言特性的支持时,我们发现了三个常见问题:

首先,语言服务器通常是用它们的本地编程语言实现的,这在将它们与具有节点的VS代码集成方面提出了挑战。js运行时。

此外,语言特性可能是资源密集型的。例如,要正确地验证一个文件,Language Server需要解析大量文件,为它们构建抽象语法树并执行静态程序分析。这些操作可能导致大量的CPU和内存使用,我们需要确保VS代码的性能不受影响。

最后,将多种语言工具与多种代码编辑器集成可能需要大量的工作。从语言工具的角度来看,它们需要适应具有不同api的代码编辑器。从代码编辑的角度来看,他们不能期望语言工具提供统一的API。这使得在N个代码编辑器中实现对M语言的支持成为M * N的工作。

为了解决这些问题,微软指定了语言服务器协议,规范了语言工具和代码编辑器之间的通信。通过这种方式,语言服务器可以在任何语言中实现,并在自己的进程中运行,以避免性能成本,因为它们通过语言服务器协议与代码编辑器通信。此外,任何符合lsp的语言工具都可以与多个符合lsp的代码编辑器集成,任何符合lsp的代码编辑器都可以轻松地获取多个符合lsp的语言工具。LSP是语言工具提供商和代码编辑器供应商的双赢!

lsp

在本指南中,我们将:

  • 说明如何使用提供的Node SDK在VS代码中构建语言服务器扩展。
  • 解释如何运行、调试、记录和测试语言服务器扩展。
  • 为您介绍一些语言服务器上的高级主题。

实现语言的服务器

概述

在VS Code中,语言服务器有两部分:

  • 语言客户端:用JavaScript / TypeScript编写的普通VS代码扩展。这个扩展可以访问所有VS Code命名空间API
  • 语言服务器:在独立进程中运行的语言分析工具。

如上所述,在单独的进程中运行语言服务器有两个好处:

  • 分析工具可以用任何语言实现,只要它能够按照语言服务器协议与语言客户机通信。
  • 由于语言分析工具通常占用大量CPU和内存,因此在单独的进程中运行它们可以避免性能开销。

下面是VS代码运行两个语言服务器扩展的示例。HTML语言客户端和PHP语言客户端是用TypeScript编写的普通VS代码扩展。它们中的每个实例化一个相应的语言服务器,并通过LSP与它们通信。虽然PHP语言服务器是用PHP编写的,但它仍然可以通过LSP与PHP语言客户端通信。

illustration

本指南将教你如何使用我们的Node SDK构建语言客户机/服务器。剩下的文档假设您熟悉VS Code的正常扩展开发。

lsp示例 - 一个简单的语言服务器纯文本文件

让我们构建一个简单的语言服务器扩展,为纯文本文件实现自动完成和诊断。我们还将介绍客户机/服务器之间配置的同步。

如果你喜欢直接进入代码:

lsp示例:本指南的大量文档化源代码。
lsp-multiserver-sample:大量文档化的lsp-sample高级版本,每个工作区文件夹启动一个不同的服务器实例,以支持VS代码中的多根工作区特性。
克隆存储库Microsoft/vscode-extension-samples并打开示例:

1
2
3
4
5
> git clone https://github.com/Microsoft/vscode-extension-samples.git
> cd vscode-extension-samples/lsp-sample
> npm install
> npm run compile
> code .

上面的步骤将安装所有依赖项,并打开包含客户机和服务器代码的lsp示例工作区。下面是lsp-sample结构的粗略概述:

1
2
3
4
5
6
7
8
9
.
├── 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,它描述了语言客户机的功能。有三个有趣的部分:

首先看activationEvents:

1
2
3
"activationEvents": [
"onLanguage:plaintext"
]

本节告诉VS代码在打开纯文本文件(例如带有扩展名.txt的文件)时立即激活扩展名。

接下来看配置部分:

1
2
3
4
5
6
7
8
9
10
11
12
"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代码提供配置设置。这个示例将解释这些设置如何在启动时和每次更改设置时发送到语言服务器。

实际的语言客户端代码和相应的包。json在/client文件夹中。包装中有趣的部分。json文件是它添加了一个依赖于vscode扩展主机APIvscode-languageclient库:

1
2
3
4
"dependencies": {
"vscode": "^1.1.18",
"vscode-languageclient": "^4.1.4"
}

如前所述,客户端是作为一个普通的VS代码扩展来实现的,它可以访问所有VS Code命名空间API。

下面是对应的extension.ts文件的内容,是lsp-sample extension的条目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import * as path from 'path';
import { workspace, ExtensionContext } from 'vscode';

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

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> {
if (!client) {
return undefined;
}
return client.stop();
}

解释语言服务器

注意:从GitHub存储库克隆出来的“服务器”实现有最终的演练实现。要执行演练,您可以创建一个新的server.ts 或修改克隆版本的内容。

在这个示例中,服务器也在TypeScript实现,并使用Node.js执行。因为VS代码已经附带了一个Node.js运行时,没有必要提供你自己的,除非你有非常具体的要求运行时。

语言服务器的源代码在/server。服务器的package.json文件中有趣的部分是:

1
2
3
"dependencies": {
"vscode-languageserver": "^4.1.3"
}

这就引入了vscode-languageserver库。

下面是一个服务器实现,它使用提供的简单文本文档管理器来同步文本文档,方法是始终将文件的完整内容从VS Code发送到服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import {
createConnection,
TextDocuments,
TextDocument,
Diagnostic,
DiagnosticSeverity,
ProposedFeatures,
InitializeParams,
DidChangeConfigurationNotification,
CompletionItem,
CompletionItemKind,
TextDocumentPositionParams
} from 'vscode-languageserver';

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

// Create a simple text document manager. The text document manager
// supports full document sync only
let documents: TextDocuments = new TextDocuments();

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 will 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;

return {
capabilities: {
textDocumentSync: documents.syncKind,
// Tell the client that the server supports code completion
completionProvider: {
resolveProvider: true
}
}
};
});

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;

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 VSCode.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

connection.onDidChangeWatchedFiles(_change => {
// Monitored files have change in VSCode
connection.console.log('We received an 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;
}
);


/*
connection.onDidOpenTextDocument((params) => {
// A text document got opened in VSCode.
// params.uri uniquely identifies the document. For documents store on disk this is a file URI.
// params.text the initial full content of the document.
connection.console.log(`${params.textDocument.uri} opened.`);
});
connection.onDidChangeTextDocument((params) => {
// The content of a text document did change in VSCode.
// params.uri uniquely identifies the document.
// params.contentChanges describe the content changes to the document.
connection.console.log(`${params.textDocument.uri} changed: ${JSON.stringify(params.contentChanges)}`);
});
connection.onDidCloseTextDocument((params) => {
// A text document got closed in VSCode.
// params.uri uniquely identifies the document.
connection.console.log(`${params.textDocument.uri} closed.`);
});
*/

// 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();

添加一个简单的验证

要向服务器添加文档验证,我们向文本文档管理器添加一个侦听器,当文本文档的内容发生更改时,该侦听器就会被调用。然后由服务器决定何时验证文档的最佳时间。在示例实现中,服务器验证纯文本文档并标记使用所有大写字母的所有出现的单词。对应的代码片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 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) => {
// 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;

let problems = 0;
let diagnostics: Diagnostic[] = [];
while ((m = pattern.exec(text))) {
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 VSCode.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

诊断提示和技巧

  • 如果起始位置和结束位置相同,VS Code将在该位置用一个弯曲的单词下划线。
  • 如果您想在一行的末尾处用波浪线进行下划线,那么请将结束位置的字符设置为Number.MAX_VALUE。

要运行语言服务器,请执行以下操作:

  • 按⇧⌘B开始构建任务。任务同时编译客户机和服务器。
  • 打开debug viewlet,选择Launch Client Launch configuration并按下Start debug按钮来启动VS代码的附加扩展开发主机实例,该实例执行扩展代码。
  • 创建一个test.txt文件在根文件夹中粘贴如下内容:
1
2
3
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.

扩展开发主机实例将如下所示:

validation

调试客户端和服务器

调试客户机代码与调试普通扩展一样容易。在客户端代码中设置断点并按F5调试扩展。有关启动和调试扩展的详细描述,请参阅开发扩展

client

由于服务器是由扩展(客户机)中运行的LanguageClient启动的,因此我们需要将调试器附加到运行的服务器上。为此,切换到Debug视图并选择服务器的启动配置并按F5。这将把调试器附加到服务器。

server

对语言服务器的日志记录支持

如果使用vscode-languageclient实现客户机,可以指定一个设置[langId].trace.server。指示客户机将语言客户机/服务器之间的通信记录到语言客户机名称的通道的服务器。

对于lsp-sample,您可以设置此设置:"languageServerExample.trace.server": "verbose"。现在转到频道“语言服务器示例”。您应该看到日志:

log

由于语言服务器可能很健谈(实际使用的5秒钟可以产生5000行日志),我们还提供了一个工具来可视化和过滤语言客户机/服务器之间的通信。您可以将通道中的所有日志保存到一个文件中,并使用语言服务器协议检查器[(https://github.com/microsoft/languageserver-protocol-inspector)加载该文件,网址是https://microsoft.github.io/languageserver-protocol/inspector)。

inspector

使用服务器中的配置设置

在编写扩展的客户端部分时,我们已经定义了一个设置来控制报告的最大问题数量。我们还在服务器端编写代码从客户端读取这些设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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;

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 VSCode.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

通过向连接添加配置更改的通知处理程序,可以完成配置更改的处理。对应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
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,将导致以下验证:

validationOneProblem

添加额外的语言特性

语言服务器通常实现的第一个有趣特性是文档验证。从这个意义上说,甚至连棉绒都可以算作语言服务器,而在VS中,代码棉绒通常被实现为语言服务器(请参阅eslint和jshint示例)。但是语言服务器还有更多的功能。它们可以提供代码完成、查找所有引用或转到Definition。下面的示例代码将代码完成添加到服务器。它提出了两个词“TypeScript”和“JavaScript”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 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 resolve 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的,因此数据字段应该只保存可序列化的JSON和可序列化的JSON数据。

所缺少的就是告诉VS Code服务器支持代码完成请求。为此,在initialize处理器中标记相应的功能:

1
2
3
4
5
6
7
8
9
10
11
12
connection.onInitialize((params): InitializeResult => {
...
return {
capabilities: {
...
// Tell the client that the server supports code completion
completionProvider: {
resolveProvider: true
}
}
};
});

下面的屏幕截图显示了运行在纯文本文件上的完整代码:

codeComplete

语言测试服务器

要创建高质量的语言服务器,我们需要构建一个覆盖其功能的良好测试套件。有两种测试语言服务器的常见方法:

  • 单元测试:如果您想通过模拟发送给语言服务器的所有信息来测试语言服务器中的特定功能,那么这是非常有用的。VS代码的HTML / CSS / JSON语言服务器采用这种方法进行测试。LSP npm模块本身使用这种方法。有关使用npm协议模块编写的单元测试,请参阅此处。
  • 端到端测试:这类似于VS Code扩展测试。这种方法的好处是,它通过使用工作区实例化VS代码实例、打开文件、激活语言客户机/服务器和运行VS Code命令来运行测试。如果您有很难或不可能模拟的文件、设置或依赖项(如node_modules),那么这种方法会更好。流行的Python扩展采用这种方法进行测试。

可以在您选择的任何测试框架中进行单元测试。在这里,我们描述了如何对语言服务器扩展进行端到端测试。

打开 .vscode/launch.json,您可以找到E2E测试目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "Language Server E2E Test",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceRoot}",
"--extensionTestsPath=${workspaceRoot}/client/out/test",
"${workspaceRoot}/client/testFixture"
],
"stopOnEntry": false,
"sourceMaps": true,
"outFiles": ["${workspaceRoot}/client/out/test/**/*.js"]
}

如果您运行这个调试目标,它将启动一个VS代码实例,client/testFixture作为活动工作区。VS代码将继续执行 client/src/test中的所有测试。作为一个调试技巧,您可以在client/src/test中的TypeScript文件中设置断点,它们将被命中。

让我们看一下completion.test.ts文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import * as vscode from 'vscode';
import * as assert from 'assert';
import { getDocUri, activate } from './helper';

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

it('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.equal(actualCompletionList.items.length, expectedCompletionList.items.length);
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)函数。客户端/src/test/helper.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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.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代码命令,并对返回的结果进行断言。

还有一个测试涵盖了我们刚刚实现的诊断特性。在client/src/test/diagnostics.test.ts中查看。

高级的主题

到目前为止,本指南包括:

  • 简要概述语言服务器和语言服务器协议。
  • VS Code中语言服务器扩展的体系结构
  • lsp示例扩展,以及如何开发/调试/检查/测试它。

还有一些更高级的话题,我们无法适应这个指南。我们将包括这些资源的链接,以进一步研究语言服务器开发。

其他语言的服务器功能

语言服务器目前支持以下语言特性以及代码完成:

  • 文档高亮显示:高亮显示文本文档中所有“相等”的符号。
  • 悬停:为文本文档中选择的符号提供悬停信息。
  • 签名帮助:为文本文档中选择的符号提供签名帮助。
  • G-oto定义:为文本文档中选择的符号提供go - to定义支持。
  • 转到类型定义:为文本文档中选择的符号提供转到类型/接口定义支持。
  • Goto实现:为文本文档中选择的符号提供go to实现定义支持。
  • 查找引用:查找文本文档中所选符号的所有项目范围的引用。
  • 列出文档符号:列出文本文档中定义的所有符号。
  • 列表工作区符号:列出所有项目范围的符号。
  • 代码操作:为给定的文本文档和范围运行计算命令(通常是美化/重构)。
  • CodeLens:计算给定文本文档的CodeLens统计数据。
  • 文档格式:这包括对整个文档的格式、文档范围和类型的格式。
  • 重命名:项目范围内的符号重命名。
  • 文档链接:计算和解析文档中的链接。
  • 文档颜色:在文档中计算和解析颜色,以便在编辑器中提供颜色选择器。

语言扩展指南主题描述了上面的每种语言特性,并提供了关于如何通过语言服务器协议或直接使用扩展的可扩展性API来实现它们的指导。

增量文本文档同步

这个示例使用vscode-languageserver模块提供的简单文本文档管理器来同步VS代码和语言服务器之间的文档。

这有两个缺点:

  • 由于文本文档的全部内容被反复发送到服务器,因此需要进行大量的数据传输。
  • 如果使用现有的语言库,这些库通常支持增量文档更新,以避免不必要的解析和抽象语法树创建。

因此,协议也支持增量文档同步。

要使用增量文档同步,服务器需要安装三个通知处理程序:

  • onDidOpenTextDocument:在VS代码中打开文本文档时调用。
  • onDidChangeTextDocument:在VS代码中文本文档的内容发生更改时调用。
  • onDidCloseTextDocument:当VS代码中关闭文本文档时调用。

下面是一个代码片段,演示了如何将这些通知处理程序与连接挂钩,以及如何在initialize上返回正确的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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.
});

直接使用VS代码API实现语言特性

虽然语言服务器有很多优点,但它们并不是扩展VS代码编辑功能的唯一选择。如果您想为一种文档类型添加一些简单的语言特性,可以考虑使用vscode.languages.register[LANGUAGE_FEATURE]Provider作为选项。

下面是一个使用vscode.languages的完整示例。registerCompletionItemProvider添加一些片段作为纯文本文件的补充。

更多说明VS代码API用法的示例可以在https://github.com/Microsoft/vscode-extension-samples找到。

语言服务器的容错解析器

大多数时候,编辑器中的代码是不完整的,在语法上是不正确的,但是开发人员仍然希望自动完成和其他语言特性能够工作。因此,语言服务器需要一个容错解析器:解析器从部分完整的代码生成有意义的AST,语言服务器根据AST提供语言特性。

当我们在VS代码中改进PHP支持时,我们意识到官方PHP解析器不能容忍错误,不能在语言服务器中直接重用。因此,我们研究了Microsoft/ tolerance -php解析器,并留下了详细的注释,这些注释可能有助于需要实现容错解析器的语言服务器作者。

常见问题

当我试图连接到服务器时,我得到“不能连接到运行时进程(5000毫秒后超时)”?

如果试图附加调试器时服务器没有运行,您将看到这个超时错误。客户端启动语言服务器,所以确保您已经启动了客户端,以便拥有一个正在运行的服务器。如果客户端断点干扰启动服务器,您可能还需要禁用它们。

Powered by Hexo and Hexo-theme-hiker

Copyright © 2013 - 2021 朝着牛逼的道路一路狂奔 All Rights Reserved.

访客数 : | 访问量 :