# 示例:语言服务器

就如你在程序性语言特性章节所见,实现语言特性的直接方式是使用languages.*API。但是语言服务器不同,它是另一种语言插件的实现方式。

本章将:

# 为什么使用语言服务器?


语言服务器是一种可以提升语言编辑体验的特殊VS Code插件。有了语言服务器,你可以实现如自动补全、错误检查(诊断)、转跳到定义等等其他VS Code语言特性

但是在VS Code中实现语言功能会面临三个问题:

第一,语言服务器一般是用他们自己原生的语言实现的,那么如何与VS Code中的Node.js运行时整合起来就是一个问题。

其二,语言服务器一般都是高消耗的。比如检查文件,语言服务器需要解析大量的文件,构建起抽象语法树然后进行静态分析。这些操作会吃掉很多CPU和内存,但是与此同时VS Code的性能不能受到任何影响。

第三,通常为多个编辑器开发不同的语言插件需要花费大量精力。对于语言插件开发者来说,他们需要根据不同编辑器各自的API来实现插件。而从编辑器的角度来讲,他们也不能指望语言工具API统一。最终导致了为N种编辑器实现M种语言需要花费N*M的工作和精力。

为了解决这些问题,微软提供了语言服务器协议(Language Server Protocol)意图为语言插件和编辑器提供社区规范。这样一来,语言服务器就可以用任何一种语言来实现,用协议通讯也避免了插件在主进程中运行的高开销。而且任何LSP兼容的语言插件,都能和LSP兼容的代码编辑器整合起来,LSP是语言插件开发者和第三方编辑器的共赢方案。

lsp-languages-editors

在本章,我们将:

  • 根据Node SDK,学习如何在VS Code中新建一个语言服务器插件
  • 学习如何运行、调试、记录日志和测试语言服务器插件
  • 为你提供更多进阶的语言服务器

TIP

本文及其他章节所涉及的LSP全为Language Server Protocol的缩写。语言服务器协议是VS Code为了调试、分析语言的自带的中间层协议。众所周知,VS Code本身只是一个编辑器,它不含任何编程语言的功能和运行时(javascript和typescript除外),而是将语言的各种特性交给了插件创作者自由实现。

# 实现你自己的语言服务器


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

  • 语言客户端:一个由Javascript/Typescript组成的普通插件,这个插件能使用所有的VS Code 命名空间API
  • 语言服务器:运行在单独进程中的语言分析工具。

语言服务器运行在单独的进程有两个好处:

  • 只要能通过LSP通信,语言分析工具可以用任何语言实现。
  • 语言分析工具一般非常消耗CPU和内存,在单独的进程中运行能避免大性能开销

下面是一个运行了2个语言服务器插件的示意图。HTML语言客户端和PHP语言客户端是常见的VS Code插件。两个客户端都用LSP与各自对应的语言服务器进行通信——即使PHP语言服务器是用PHP写的,但是仍然能通过LSP与PHP语言客户端建立起通信。

lsp-illustration

本篇将指引你学习如何用我们的Node SDK构建一个语言客户端/服务器。剩下的内容都建立在你已经了解VS Code插件开发的基础之上。

# 示例:一个简单的纯文本语言服务器


让我们首先实现一个简单的语言服务器插件吧,这个插件的功能是自动补全、诊断纯文本文件。我们会同时学习客户端/服务端的配置。 如果你想直接上手代码:

复制Microsoft/vscode-extension-samples然后打开示例:

> cd lsp-sample
> npm install
> npm run compile
> code .
1
2
3
4

安装完所有依赖然后打开lsp-sample工作,里面包含客户端和服务器的代码。下面是一个整体的lsp-sample目录结构:

.
├── client // 语言客户端
│   ├── src
│   │   ├── test // 语言客户端 / 服务器 的端到端测试
│   │   └── extension.ts // 语言客户端入口
├── package.json // 插件配置清单
└── server // 语言服务器
    └── src
        └── server.ts // 语言服务器入口
1
2
3
4
5
6
7
8
9

# 什么是'Language Client'


我们先看看/package.json,这个文件描述了语言客户端的能力。里面有3个有趣的部分:

首先看看activationEvents

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

这个部分告诉VS Code只要打开纯文本文件之后就立刻激活插件(例如:打开一个.txt文件)

下一步看看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."
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

这个部分配置了用户可以自定义的configuration,用户通过这个配置可以在设置中对你的插件做一些修改。这并不是本节重点,稍后示例将通过代码呈现——插件如何在设置变动后将修改后的配置应用到我们的语言服务器上。

真正的语言客户端代码和对应的package.json/client文件夹中。package.json最有趣的部分是vscode插件主机API和vscode-languageclient这两个依赖库。

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

正如上面所说,客户端实现就是一个普通的VS Code插件,它有使用全部VS Code API的能力。

下面是extension.ts文件的对应内容,也是lsp-sample插件的入口:

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) {
	// 服务器由node实现
	let serverModule = context.asAbsolutePath(
		path.join('server', 'out', 'server.js')
	);
	// 为服务器提供debug选项
	// --inspect=6009: 运行在Node's Inspector mode,这样VS Code就能调试服务器了
	let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };

	// 如果插件运行在调试模式那么就会使用debug server options
	// 不然就使用run options
	let serverOptions: ServerOptions = {
		run: { module: serverModule, transport: TransportKind.ipc },
		debug: {
			module: serverModule,
			transport: TransportKind.ipc,
			options: debugOptions
		}
	};

	// 控制语言客户端的选项
	let clientOptions: LanguageClientOptions = {
		// 注册纯文本服务器
		documentSelector: [{ scheme: 'file', language: 'plaintext' }],
		synchronize: {
			// 当文件变动为'.clientrc'中那样时,统治服务器
			fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
		}
	};
    // 创建语言客户端并启动
	client = new LanguageClient(
		'languageServerExample',
		'Language Server Example',
		serverOptions,
		clientOptions
	);

	// 启动客户端,这也同时启动了服务器
	client.start();
}

export function deactivate(): Thenable<void> {
	if (!client) {
		return undefined;
	}
	return client.stop();
}

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

# 什么是'Language Server'


TIP

本节从Github仓库中克隆下来的'server'代码是已经完成的版本,如果你需要跟随本节的步骤循序渐进,你可以新建一个server.ts或者修改克隆的代码。

在这个例子中,服务器是Typescript实现的,由Node.js运行。因为VS Code自带Node.js运行时,所以你无需安装其他依赖,除非你对运行时有特别要求。

这个语言服务器的源码在/server中。比较重要的pacakge.json部分是:

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

这行依赖会下载vscode-languageserver库。

下面是一个服务器的实现,提供了简单的纯文本管理——VS Code会向服务器发送一个文件的全部内容。

import {
	createConnection,
	TextDocuments,
	TextDocument,
	Diagnostic,
	DiagnosticSeverity,
	ProposedFeatures,
	InitializeParams,
	DidChangeConfigurationNotification,
	CompletionItem,
	CompletionItemKind,
	TextDocumentPositionParams
} from 'vscode-languageserver';

// 创建一个服务器连接。使用Node的IPC作为传输方式。
// 也包含所有的预览、建议等LSP特性
let connection = createConnection(ProposedFeatures.all);

// 创建一个简单的文本管理器。
// 文本管理器只支持全文本同步。
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;

	// 客户端是否支持`workspace/configuration`请求?
	// 如果不是的话,降级到使用全局设置
	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,
			// 告诉客户端,服务器支持代码补全
			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.');
		});
	}
});

// 配置示例
interface ExampleSettings {
	maxNumberOfProblems: number;
}

// 当客户端不支持`workspace/configuration`请求时,使用global settings
// 请注意,在这个例子中服务器使用的客户端并不是问题所在,而是这种情况还可能发生在其他客户端身上。
const defaultSettings: ExampleSettings = { maxNumberOfProblems: 1000 };
let globalSettings: ExampleSettings = defaultSettings;

// 对所有打开的文档配置进行缓存
let documentSettings: Map<string, Thenable<ExampleSettings>> = new Map();

connection.onDidChangeConfiguration(change => {
	if (hasConfigurationCapability) {
		// 重置所有已缓存的文档配置
		documentSettings.clear();
	} else {
		globalSettings = <ExampleSettings>(
			(change.settings.languageServerExample || defaultSettings)
		);
	}

	// 重新验证所有打开的文本文档
	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;
}

// 只对打开的文档保留设置
documents.onDidClose(e => {
	documentSettings.delete(e.document.uri);
});

// 文档的文本内容发生了改变。
// 这个事件在文档第一次打开或者内容变动时才会触发。
documents.onDidChangeContent(change => {
	validateTextDocument(change.document);
});

async function validateTextDocument(textDocument: TextDocument): Promise<void> {
	// 在这个简单的示例中,每次校验运行时我们都获取一次配置
	let settings = await getDocumentSettings(textDocument.uri);

	// 校验器如果检测到连续超过2个以上的大写字母则会报错
	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 diagnosic: 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) {
			diagnosic.relatedInformation = [
				{
					location: {
						uri: textDocument.uri,
						range: Object.assign({}, diagnosic.range)
					},
					message: 'Spelling matters'
				},
				{
					location: {
						uri: textDocument.uri,
						range: Object.assign({}, diagnosic.range)
					},
					message: 'Particularly for names'
				}
			];
		}
		diagnostics.push(diagnosic);
	}
    // 将错误处理结果发送给VS Code
	connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

connection.onDidChangeWatchedFiles(_change => {
	// 监测VS Code中的文件变动
	connection.console.log('We received an file change event');
});

// 这个处理函数提供了初始补全项列表
connection.onCompletion(
	(_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
	// 传入的变量包含了文本请求代码补全的位置。
	// 如果我们忽略了这个信息,那就只能提供同样的代码补全项了。
	return [
		{
			label: 'TypeScript',
			kind: CompletionItemKind.Text,
			data: 1
		},
		{
			label: 'JavaScript',
			kind: CompletionItemKind.Text,
			data: 2
		}
		];
	}
);

// 这个函数为补全列表的选中项提供了更多信息
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.`);
});
*/

// 让文档管理器监听文档的打开,变动和关闭事件。
documents.listen(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
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

# 添加一个简单的语法校验器


为了给服务器添加文本校验,我们给text document manager添加一个listener然后在文本变动时调用,接下来就交给服务器去判断调用校验器的最佳时机了。在我们的示例中,服务器的功能是校验纯文本然后给所有大写单词进行标记。对应的代码片段:

// 事件在文档第一次打开,或者内容变动时触发。
documents.onDidChangeContent(async (change) => {
	// 在这个简单的示例中,每次校验运行时我们都获取一次配置
	let settings = await getDocumentSettings(textDocument.uri);

	// 校验器如果检测到连续超过2个以上的大写字母则会报错
	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 diagnosic: 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) {
			diagnosic.relatedInformation = [
				{
					location: {
						uri: textDocument.uri,
						range: Object.assign({}, diagnosic.range)
					},
					message: 'Spelling matters'
				},
				{
					location: {
						uri: textDocument.uri,
						range: Object.assign({}, diagnosic.range)
					},
					message: 'Particularly for names'
				}
			];
		}
		diagnostics.push(diagnosic);
	}

	// 将错误处理结果发送给VS Code
	connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}
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

# 诊断提示和小技巧


  • 如果出错的开始点和结束点在同一个位置,VS Code会在那个单词的位置上打上波浪线
  • 如果你想要把波浪线加到行未为止,就把end position设置为Number.MAX_VALUE

运行语言服务器步骤:

  1. 通过快捷键(Ctrl+Shift+B)启动build任务。这个任务会把客户端和服务器端都编译掉。
  2. 打开调试侧边栏,选择启动客户端加载配置,然后按开始调试按钮启动扩展开发主机
  3. 在根目录下新建一个'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.
1
2
3

扩展开发主机实例看起来像是这样:

validation

# 调试客户端和服务端


调试客户端代码就像调试普通插件一样简单。在代码中打上断点,然后按F5启动插件调试。

debugging-client

因为服务器是由LanguageClient启动的,我们需要附加一个调试器给运行中的服务器。为了做到这一点,切换到调试侧边栏,选择加载配置Attach to Server然后按F5启动调试(要保证server已经启动哦,也就是上面一步),看起来会像这样:

debugging-server

# 为语言服务器加上日志


如果你是用vscode-languageclient实现的客户端,你可以配置[langId].trace.server指示客户端在output(输出)面板中显示通信日志。

对于Isp-sample你能在"languageServerExample.trace.server": "verbose"进行配置。现在看看"Language Server Example"频道,你应该能看到这些日志:

lsp-log

因为语言服务器通信会非常啰嗦(5s的正常使用会产生5000行日志),因此我们提供了一个可视化和可筛选的日志工具。你可以先从频道中保存所有的日志,然后在语言服务器协议检查器中加载。

lsp-inspector

# 在服务器中设置Configuration


当我们写插件的客户端部分的时候,我们已经定义了一个控制最大问题报告数的配置。所以我们也可以在服务器中写一段读取客户端配置的代码:

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

现在唯一要做的事情就是在服务器端中监听用户修改的设置变动,然后重新验证已经打开的文本文件。为了重用文本变动事件的处理函数,我们把代码提取到validateTextDocument函数中,然后新建一个maxNumberOfProblems变量:

async function validateTextDocument(textDocument: TextDocument): Promise<void> {
	// 在这个简单的示例中,每次校验运行时我们都获取一次配置
	let settings = await getDocumentSettings(textDocument.uri);

	// 校验器如果检测到连续超过2个以上的大写字母则会报错
	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 diagnosic: 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) {
			diagnosic.relatedInformation = [
				{
					location: {
						uri: textDocument.uri,
						range: Object.assign({}, diagnosic.range)
					},
					message: 'Spelling matters'
				},
				{
					location: {
						uri: textDocument.uri,
						range: Object.assign({}, diagnosic.range)
					},
					message: 'Particularly for names'
				}
			];
		}
		diagnostics.push(diagnosic);
	}

	// 将错误处理结果发送给VS Code
	connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}
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

添加一个通知处理函数监听配置文件变动。

connection.onDidChangeConfiguration(change => {
	if (hasConfigurationCapability) {
		// Reset all cached document settings
		documentSettings.clear();
	} else {
		globalSettings = <ExampleSettings>(
			(change.settings.languageServerExample || defaultSettings)
		);
	}

	// 重新验证所有打开的文本文档
	documents.all().forEach(validateTextDocument);
});
1
2
3
4
5
6
7
8
9
10
11
12
13

再次启动客户端,然后把设置中的maximum report改为1,就能看到:

validationOneProblem

# 添加其他语言特性


第一个有趣的东西是,语言服务器通常会实现成文档校验器,从这个点来说,即使一个linter也算一个语言服务器,所以VS Code中的linter通常都是作为语言服务器实现的(参照eslintjslint)。但是语言服务器还能做得更多,他们能提供代码不全,查找所有匹配项或者转跳到定义。下面的代码展示了为服务器添加代码补全的功能,它提供了2个建议单词"TypeScript"和"JavaScript"。

// 这个处理函数提供了初始补全项列表
connection.onCompletion(
	(_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
		// 传入的变量包含了文本请求代码补全的位置。
		// 如果我们忽略了这个信息,那就只能提供同样的代码补全项了。
		return [
			{
				label: 'TypeScript',
				kind: CompletionItemKind.Text,
				data: 1
			},
			{
				label: 'JavaScript',
				kind: CompletionItemKind.Text,
				data: 2
			}
		];
	}
);

// 这个函数为补全列表的选中项提供了更多信息
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;
	}
);
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

data字段用于鉴别处理函数中传入的补全项。这个属性对协议来说是透明的,因为底层协议信息传输是基于JSON的,因此data字段只能保留从JSON序列化而来的数据。

那么现在只缺告诉VS Code服务器能提供代码补全请求。为了做到点,将对应标记添加到初始化函数中:

connection.onInitialize((params): InitializeResult => {
	...
	return {
		capabilities: {
			...
			// 告诉客户端,服务器支持代码补全
			completionProvider: {
				resolveProvider: true
			}
		}
	};
});
1
2
3
4
5
6
7
8
9
10
11
12

下面的截屏显示了运行在纯文本文件中的补全代码:

codeComplete

# 测试语言服务器


为了创建一个高质量的语言服务器,我们需要构建一个能覆盖到它所有功能点的测试套件。有两种常见的测试服务器的方式:

  • 单元测试:如果你想测试特定的功能点,这是一个非常有用的方式,模拟数据然后发送进去。VC 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",
		"${workspaceRoot}/client/testFixture"
	],
	"stopOnEntry": false,
	"sourceMaps": true,
	"outFiles": ["${workspaceRoot}/client/out/test/**/*.js"]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

如果你运行了这个测试目标,它会打开一个VS Code实例和一个叫做client/testFixtur的激活工作区。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';

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

	// 执行 `vscode.executeCompletionItemProvider` 命令,模拟激活代码补全功能
	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);
	});
}
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

在这个测试中,我们:

  • 激活了插件
  • 带上了一个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;

/**
 * 激活 vscode.lsp-sample 插件
 */
export async function activate(docUri: vscode.Uri) {
	// extensionId来自于package.json中的`publisher.name`
	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); // 等待服务器激活
	} catch (e) {
		console.error(e);
	}
}

async function sleep(ms: number) {
	return new Promise(resolve => setTimeout(resolve, ms));
}
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

在激活部分,我们:

  • publisher.name extensionIdpackage.json中获取到了插件
  • 打开特定的文档,然后显示在文本编辑区
  • 休眠2秒,确保启动了语言服务器

准备好之后,我们可以运行对应语言特性的VS Code命令,然后对结果进行断言测试。 这还有一个关于诊断特性的测试实现,如果你感兴趣,可以查看这个文件client/src/test/diagnostics.test.ts

# 进阶主题


到目前为止,本篇教程提供了:

  • 一个简短的语言服务器语言服务器协议概览
  • VS Code中的语言服务器插件架构
  • 实现了一个Isp-sample插件,和如何开发、调试、检查和测试语言服务器

# 更多语言服务器特性

除了代码补全之外,VS Code还支持下列特性:

  • 文档高亮:高亮文本中的符号
  • 悬停:为选中的文本符号提供悬停信息
  • Signature Help:为选中的文本提供提供Signature Help
  • 转跳到定义:为选中的文本符号提供定义转跳
  • 转跳到类型定义:为选中的文本符号提供类型/接口定义转跳
  • 转跳到实现:为选中的文本符号提供实现转跳
  • 引用查找:从整个项目中查找选中文本符号的引用
  • 列出文件符号:列出文本文件中的全部符号
  • 列出工作区符号:列出整个项目中的符号
  • 执行代码:在给定文件和范围的条件下运行命令(通常如:美化、重构)
  • CodeLens: 为给定文件计算 CodeLens 统计数据
  • 文件格式化:包括整个文件的格式化,部分文本格式化和根据类型格式化
  • 重命名:重命名整个项目内的某些符号
  • 文件链接:计算和解析文件中的链接
  • 文件色彩:计算和解析文件中的色彩,并提供编辑器内的取色器

程序性语言特性章节详细介绍了上述的语言特性,并且告诉我们如何通过下述(两者之一)去实现它们:

  • 语言服务器协议
  • 直接使用VS Code的可拓展性API

# 增量文本同步更新


vscode-languageserver模块中,我们做了一个简单的text document manager同步VS Code和语言服务器。

但是这种方式有两个缺点:

  • 文件变动时,会重复地发送整个文本数据,这个传递的数据量相当可观。
  • 现有的库通常都支持增量文本更新,不可避免地,我们会进行不必要的转换和创建抽象语法树。

LSP因此直接提供了增量文本更新的API。

现在我们要通过增加3个通知函数实现我们的增量文本更新:

  • onDidOpenTextDocument:当文件打开后调用
  • onDidChangeTextDocument:当文本变动后调用
  • onDidCloseTextDocument:当文件关闭后调用

下面的代码片段展示了怎么在通信中挂上这些通知函数钩子,在初始化时因如何返回函数:

connection.onInitialize((params): InitializeResult => {
	...
	return {
		capabilities: {
			// 启用文档增量更新同步
			textDocumentSync: TextDocumentSyncKind.Incremental,
			...
		}
	};
});

connection.onDidOpenTextDocument((params) => {
	// 当文档打开后触发,params.uri提供了文档的唯一地址。如果文档储存在硬盘上,那么就会是一个file类型的URI
	// params.text——提供了文档一开始的内容
});

connection.onDidChangeTextDocument((params) => {
	// 文档的文本内容发生了改变时触发。
	// params.uri提供了文档的唯一地址。
	// params.contentChanges 包含文档的变动内容
});

connection.onDidCloseTextDocument((params) => {
	// 文档关闭后触发。
	// params.uri提供了文档的唯一地址。
});
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

# 直接用VS Code API实现语言特性

语言服务器有这么多好处,只是用来提供VS Code编辑扩展能力就显得有些大材小用了。下面的例子里,我们使用vscode.languages.register[LANGUAGE_FEATURE]Provider选项为某类文件提供一些简单的语言服务器特性。

completions-sample是一个使用vscode.languages.registerCompletionItemProvider为纯文本添加代码片段的例子。

更多例子请参阅https://github.com/Microsoft/vscode-extension-samples

# 语言服务器的容错解析器

大多数时候,编辑器中的代码都是不完整的,甚至语法都是错的,但是开发人员肯定希望自动补全等语言功能保持正常工作。因此,容错解析器就显得十分必要:解析器仍能从不完整的代码中创建有意义的AST,然后语言服务器根据这份AST提供服务。

我们之前在VS Code中做过PHP的支持,我们意识到PHP官方解析器并没有自带容错,而且也不能直接在语言服务器中直接重用。所以我们一起努力做了 Microsoft/tolerant-php-parser,并留下了详细的笔记,或许能帮上需要容错解析器的语言服务器作者。

# FAQ

  • 问:当我试着向debug添加服务器的时候,我得到了"cannot connect to runtime process (timeout after 5000ms)"的信息?

    答:如果服务器没有运行你还强行添加debbuger的时候,会出现这个超时问题,你也可能需要关闭服务器中的断点。

  • 问:虽然我看完了LSP Specification,但是我还有很多问题解决不了,我可以在哪获得帮助?

    答:可以在https://github.com/Microsoft/language-server-protocol中开issue。