示例:语言服务器

就如你在程序性语言特性章节所见,实现语言特性的直接方式是使用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中新建一个语言服务器插件
  • 学习如何运行、调试、记录日志和测试语言服务器插件
  • 为你提供更多进阶的语言服务器

?> 译者注:本文及其他章节所涉及的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然后打开示例:

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

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

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

什么是’Language Client’


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

首先看看activationEvents

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

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

下一步看看configuration部分:

  1. "configuration": {
  2. "type": "object",
  3. "title": "Example configuration",
  4. "properties": {
  5. "languageServerExample.maxNumberOfProblems": {
  6. "scope": "resource",
  7. "type": "number",
  8. "default": 100,
  9. "description": "Controls the maximum number of problems produced by the server."
  10. }
  11. }
  12. }

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

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

  1. "engines": {
  2. "vscode": "^1.43.0"
  3. },
  4. "dependencies": {
  5. "vscode-languageclient": "^6.1.3"
  6. }

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

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

  1. import * as path from 'path';
  2. import { workspace, ExtensionContext } from 'vscode';
  3. import {
  4. LanguageClient,
  5. LanguageClientOptions,
  6. ServerOptions,
  7. TransportKind
  8. } from 'vscode-languageclient';
  9. let client: LanguageClient;
  10. export function activate(context: ExtensionContext) {
  11. // 服务器由node实现
  12. let serverModule = context.asAbsolutePath(
  13. path.join('server', 'out', 'server.js')
  14. );
  15. // 为服务器提供debug选项
  16. // --inspect=6009: 运行在Node's Inspector mode,这样VS Code就能调试服务器了
  17. let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };
  18. // 如果插件运行在调试模式那么就会使用debug server options
  19. // 不然就使用run options
  20. let serverOptions: ServerOptions = {
  21. run: { module: serverModule, transport: TransportKind.ipc },
  22. debug: {
  23. module: serverModule,
  24. transport: TransportKind.ipc,
  25. options: debugOptions
  26. }
  27. };
  28. // 控制语言客户端的选项
  29. let clientOptions: LanguageClientOptions = {
  30. // 注册纯文本服务器
  31. documentSelector: [{ scheme: 'file', language: 'plaintext' }],
  32. synchronize: {
  33. // 当文件变动为'.clientrc'中那样时,通知服务器
  34. fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
  35. }
  36. };
  37. // 创建语言客户端并启动
  38. client = new LanguageClient(
  39. 'languageServerExample',
  40. 'Language Server Example',
  41. serverOptions,
  42. clientOptions
  43. );
  44. // 启动客户端,这也同时启动了服务器
  45. client.start();
  46. }
  47. export function deactivate(): Thenable<void> {
  48. if (!client) {
  49. return undefined;
  50. }
  51. return client.stop();
  52. }

什么是’Language Server’


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

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

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

  1. "dependencies": {
  2. "vscode-languageserver": "^6.1.1",
  3. "vscode-languageserver-textdocument": "^1.0.1"
  4. }

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

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

  1. import {
  2. createConnection,
  3. TextDocuments,
  4. Diagnostic,
  5. DiagnosticSeverity,
  6. ProposedFeatures,
  7. InitializeParams,
  8. DidChangeConfigurationNotification,
  9. CompletionItem,
  10. CompletionItemKind,
  11. TextDocumentPositionParams,
  12. TextDocumentSyncKind,
  13. InitializeResult
  14. } from 'vscode-languageserver';
  15. import { TextDocument } from 'vscode-languageserver-textdocument';
  16. // 创建一个服务器连接。使用Node的IPC作为传输方式。
  17. // 也包含所有的预览、建议等LSP特性
  18. let connection = createConnection(ProposedFeatures.all);
  19. // 创建一个简单的文本管理器。
  20. // 文本管理器只支持全文本同步。
  21. let documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
  22. let hasConfigurationCapability: boolean = false;
  23. let hasWorkspaceFolderCapability: boolean = false;
  24. let hasDiagnosticRelatedInformationCapability: boolean = false;
  25. connection.onInitialize((params: InitializeParams) => {
  26. let capabilities = params.capabilities;
  27. // 客户端是否支持`workspace/configuration`请求?
  28. // 如果不是的话,降级到使用全局设置
  29. hasConfigurationCapability = !!(
  30. capabilities.workspace && !!capabilities.workspace.configuration
  31. );
  32. hasWorkspaceFolderCapability = !!(
  33. capabilities.workspace && !!capabilities.workspace.workspaceFolders
  34. );
  35. hasDiagnosticRelatedInformationCapability = !!(
  36. capabilities.textDocument &&
  37. capabilities.textDocument.publishDiagnostics &&
  38. capabilities.textDocument.publishDiagnostics.relatedInformation
  39. );
  40. const result: InitializeResult = {
  41. capabilities: {
  42. textDocumentSync: TextDocumentSyncKind.Incremental,
  43. // Tell the client that this server supports code completion.
  44. completionProvider: {
  45. resolveProvider: true
  46. }
  47. }
  48. };
  49. if (hasWorkspaceFolderCapability) {
  50. result.capabilities.workspace = {
  51. workspaceFolders: {
  52. supported: true
  53. }
  54. };
  55. }
  56. return result;
  57. });
  58. connection.onInitialized(() => {
  59. if (hasConfigurationCapability) {
  60. // 为所有配置Register for all configuration changes.
  61. connection.client.register(
  62. DidChangeConfigurationNotification.type,
  63. undefined
  64. );
  65. }
  66. if (hasWorkspaceFolderCapability) {
  67. connection.workspace.onDidChangeWorkspaceFolders(_event => {
  68. connection.console.log('Workspace folder change event received.');
  69. });
  70. }
  71. });
  72. // 配置示例
  73. interface ExampleSettings {
  74. maxNumberOfProblems: number;
  75. }
  76. // 当客户端不支持`workspace/configuration`请求时,使用global settings
  77. // 请注意,在这个例子中服务器使用的客户端并不是问题所在,而是这种情况还可能发生在其他客户端身上。
  78. const defaultSettings: ExampleSettings = { maxNumberOfProblems: 1000 };
  79. let globalSettings: ExampleSettings = defaultSettings;
  80. // 对所有打开的文档配置进行缓存
  81. let documentSettings: Map<string, Thenable<ExampleSettings>> = new Map();
  82. connection.onDidChangeConfiguration(change => {
  83. if (hasConfigurationCapability) {
  84. // 重置所有已缓存的文档配置
  85. documentSettings.clear();
  86. } else {
  87. globalSettings = <ExampleSettings>(
  88. (change.settings.languageServerExample || defaultSettings)
  89. );
  90. }
  91. // 重新验证所有打开的文本文档
  92. documents.all().forEach(validateTextDocument);
  93. });
  94. function getDocumentSettings(resource: string): Thenable<ExampleSettings> {
  95. if (!hasConfigurationCapability) {
  96. return Promise.resolve(globalSettings);
  97. }
  98. let result = documentSettings.get(resource);
  99. if (!result) {
  100. result = connection.workspace.getConfiguration({
  101. scopeUri: resource,
  102. section: 'languageServerExample'
  103. });
  104. documentSettings.set(resource, result);
  105. }
  106. return result;
  107. }
  108. // 只对打开的文档保留设置
  109. documents.onDidClose(e => {
  110. documentSettings.delete(e.document.uri);
  111. });
  112. // 文档的文本内容发生了改变。
  113. // 这个事件在文档第一次打开或者内容变动时才会触发。
  114. documents.onDidChangeContent(change => {
  115. validateTextDocument(change.document);
  116. });
  117. async function validateTextDocument(textDocument: TextDocument): Promise<void> {
  118. // 在这个简单的示例中,每次校验运行时我们都获取一次配置
  119. let settings = await getDocumentSettings(textDocument.uri);
  120. // 校验器如果检测到连续超过2个以上的大写字母则会报错
  121. let text = textDocument.getText();
  122. let pattern = /\b[A-Z]{2,}\b/g;
  123. let m: RegExpExecArray | null;
  124. let problems = 0;
  125. let diagnostics: Diagnostic[] = [];
  126. while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
  127. problems++;
  128. let diagnosic: Diagnostic = {
  129. severity: DiagnosticSeverity.Warning,
  130. range: {
  131. start: textDocument.positionAt(m.index),
  132. end: textDocument.positionAt(m.index + m[0].length)
  133. },
  134. message: `${m[0]} is all uppercase.`,
  135. source: 'ex'
  136. };
  137. if (hasDiagnosticRelatedInformationCapability) {
  138. diagnosic.relatedInformation = [
  139. {
  140. location: {
  141. uri: textDocument.uri,
  142. range: Object.assign({}, diagnosic.range)
  143. },
  144. message: 'Spelling matters'
  145. },
  146. {
  147. location: {
  148. uri: textDocument.uri,
  149. range: Object.assign({}, diagnosic.range)
  150. },
  151. message: 'Particularly for names'
  152. }
  153. ];
  154. }
  155. diagnostics.push(diagnosic);
  156. }
  157. // 将错误处理结果发送给VS Code
  158. connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
  159. }
  160. connection.onDidChangeWatchedFiles(_change => {
  161. // 监测VS Code中的文件变动
  162. connection.console.log('We received an file change event');
  163. });
  164. // 这个处理函数提供了初始补全项列表
  165. connection.onCompletion(
  166. (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
  167. // 传入的变量包含了文本请求代码补全的位置。
  168. // 在这个示例中我们忽略了这个信息,总是提供相同的补全选项。
  169. return [
  170. {
  171. label: 'TypeScript',
  172. kind: CompletionItemKind.Text,
  173. data: 1
  174. },
  175. {
  176. label: 'JavaScript',
  177. kind: CompletionItemKind.Text,
  178. data: 2
  179. }
  180. ];
  181. }
  182. );
  183. // 这个函数为补全列表的选中项提供了更多信息
  184. connection.onCompletionResolve(
  185. (item: CompletionItem): CompletionItem => {
  186. if (item.data === 1) {
  187. item.detail = 'TypeScript details';
  188. item.documentation = 'TypeScript documentation';
  189. } else if (item.data === 2) {
  190. item.detail = 'JavaScript details';
  191. item.documentation = 'JavaScript documentation';
  192. }
  193. return item;
  194. }
  195. );
  196. // 让文档管理器监听文档的打开,变动和关闭事件。
  197. documents.listen(connection);
  198. // 连接后启动监听
  199. connection.listen();

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


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

  1. // 文本文件的内容改变时。文档首次打开或者文档内容修改时会触发这个事件。
  2. documents.onDidChangeContent(async change => {
  3. let textDocument = change.document;
  4. // 这个简单示例中,每次校验时我们都获取一次设置
  5. let settings = await getDocumentSettings(textDocument.uri);
  6. // 校验器会检查所有的大写单词是否超过 2 个字母
  7. let text = textDocument.getText();
  8. let pattern = /\b[A-Z]{2,}\b/g;
  9. let m: RegExpExecArray | null;
  10. let problems = 0;
  11. let diagnostics: Diagnostic[] = [];
  12. while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
  13. problems++;
  14. let diagnostic: Diagnostic = {
  15. severity: DiagnosticSeverity.Warning,
  16. range: {
  17. start: textDocument.positionAt(m.index),
  18. end: textDocument.positionAt(m.index + m[0].length)
  19. },
  20. message: `${m[0]} is all uppercase.`,
  21. source: 'ex'
  22. };
  23. if (hasDiagnosticRelatedInformationCapability) {
  24. diagnostic.relatedInformation = [
  25. {
  26. location: {
  27. uri: textDocument.uri,
  28. range: Object.assign({}, diagnostic.range)
  29. },
  30. message: 'Spelling matters'
  31. },
  32. {
  33. location: {
  34. uri: textDocument.uri,
  35. range: Object.assign({}, diagnostic.range)
  36. },
  37. message: 'Particularly for names'
  38. }
  39. ];
  40. }
  41. diagnostics.push(diagnostic);
  42. }
  43. // 将诊断信息发送给 VS Code
  44. connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
  45. });

诊断提示和小技巧


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

运行语言服务器步骤:

  1. 通过快捷键(Ctrl+Shift+B)启动build任务。这个任务会把客户端和服务器端都编译掉。
  2. 打开调试侧边栏,选择启动客户端加载配置,然后按开始调试按钮启动扩展开发主机
  3. 在根目录下新建一个’test.txt’文件,然后粘贴下述内容:
  1. TypeScript lets you write JavaScript the way you really want to.
  2. TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.
  3. ANY browser. ANY host. ANY OS. Open Source.

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

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


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

  1. function getDocumentSettings(resource: string): Thenable<ExampleSettings> {
  2. if (!hasConfigurationCapability) {
  3. return Promise.resolve(globalSettings);
  4. }
  5. let result = documentSettings.get(resource);
  6. if (!result) {
  7. result = connection.workspace.getConfiguration({
  8. scopeUri: resource,
  9. section: 'languageServerExample'
  10. });
  11. documentSettings.set(resource, result);
  12. }
  13. return result;
  14. }

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

  1. async function validateTextDocument(textDocument: TextDocument): Promise<void> {
  2. // 在这个简单的示例中,每次校验运行时我们都获取一次配置
  3. let settings = await getDocumentSettings(textDocument.uri);
  4. // 校验器如果检测到连续超过2个以上的大写字母则会报错
  5. let text = textDocument.getText();
  6. let pattern = /\b[A-Z]{2,}\b/g;
  7. let m: RegExpExecArray;
  8. let problems = 0;
  9. let diagnostics: Diagnostic[] = [];
  10. while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
  11. problems++;
  12. let diagnosic: Diagnostic = {
  13. severity: DiagnosticSeverity.Warning,
  14. range: {
  15. start: textDocument.positionAt(m.index),
  16. end: textDocument.positionAt(m.index + m[0].length)
  17. },
  18. message: `${m[0]} is all uppercase.`,
  19. source: 'ex'
  20. };
  21. if (hasDiagnosticRelatedInformationCapability) {
  22. diagnosic.relatedInformation = [
  23. {
  24. location: {
  25. uri: textDocument.uri,
  26. range: Object.assign({}, diagnosic.range)
  27. },
  28. message: 'Spelling matters'
  29. },
  30. {
  31. location: {
  32. uri: textDocument.uri,
  33. range: Object.assign({}, diagnosic.range)
  34. },
  35. message: 'Particularly for names'
  36. }
  37. ];
  38. }
  39. diagnostics.push(diagnosic);
  40. }
  41. // 将错误处理结果发送给VS Code
  42. connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
  43. }

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

  1. connection.onDidChangeConfiguration(change => {
  2. if (hasConfigurationCapability) {
  3. // 重置所有文档设置的缓存
  4. documentSettings.clear();
  5. } else {
  6. globalSettings = <ExampleSettings>(
  7. (change.settings.languageServerExample || defaultSettings)
  8. );
  9. }
  10. // 重新验证所有打开的文本文档
  11. documents.all().forEach(validateTextDocument);
  12. });

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

validationOneProblem

添加其他语言特性


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

  1. // 这个处理函数提供了初始补全项列表
  2. connection.onCompletion(
  3. (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
  4. // 传入的变量包含了文本请求代码补全的位置。
  5. // 在这个示例中我们忽略了这个信息,总是提供相同的补全选项。
  6. return [
  7. {
  8. label: 'TypeScript',
  9. kind: CompletionItemKind.Text,
  10. data: 1
  11. },
  12. {
  13. label: 'JavaScript',
  14. kind: CompletionItemKind.Text,
  15. data: 2
  16. }
  17. ];
  18. }
  19. );
  20. // 这个函数为补全列表的选中项提供了更多信息
  21. connection.onCompletionResolve(
  22. (item: CompletionItem): CompletionItem => {
  23. if (item.data === 1) {
  24. (item.detail = 'TypeScript details'),
  25. (item.documentation = 'TypeScript documentation');
  26. } else if (item.data === 2) {
  27. (item.detail = 'JavaScript details'),
  28. (item.documentation = 'JavaScript documentation');
  29. }
  30. return item;
  31. }
  32. );

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

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

  1. connection.onInitialize((params): InitializeResult => {
  2. ...
  3. return {
  4. capabilities: {
  5. ...
  6. // 告诉客户端,服务器支持代码补全
  7. completionProvider: {
  8. resolveProvider: true
  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测试目标:

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

如果你运行了这个测试目标,它会打开一个VS Code实例和一个叫做client/testFixtur的激活工作区。VS Code然后会执行所有client/src/test中的测试。一点调试的小提示,你可以在client/src/test的Typescript文件中添加断点。

我们再来看看completion.test.ts文件:

  1. import * as vscode from 'vscode';
  2. import * as assert from 'assert';
  3. import { getDocUri, activate } from './helper';
  4. describe('Should do completion', () => {
  5. const docUri = getDocUri('completion.txt');
  6. it('Completes JS/TS in txt file', async () => {
  7. await testCompletion(docUri, new vscode.Position(0, 0), {
  8. items: [
  9. { label: 'JavaScript', kind: vscode.CompletionItemKind.Text },
  10. { label: 'TypeScript', kind: vscode.CompletionItemKind.Text }
  11. ]
  12. });
  13. });
  14. });
  15. async function testCompletion(
  16. docUri: vscode.Uri,
  17. position: vscode.Position,
  18. expectedCompletionList: vscode.CompletionList
  19. ) {
  20. await activate(docUri);
  21. // 执行 `vscode.executeCompletionItemProvider` 命令,模拟激活代码补全功能
  22. const actualCompletionList = (await vscode.commands.executeCommand(
  23. 'vscode.executeCompletionItemProvider',
  24. docUri,
  25. position
  26. )) as vscode.CompletionList;
  27. assert.equal(actualCompletionList.items.length, expectedCompletionList.items.length);
  28. expectedCompletionList.items.forEach((expectedItem, i) => {
  29. const actualItem = actualCompletionList.items[i];
  30. assert.equal(actualItem.label, expectedItem.label);
  31. assert.equal(actualItem.kind, expectedItem.kind);
  32. });
  33. }

在这个测试中,我们:

  • 激活了插件
  • 带上了一个URI和位置模拟信息,然后运行了vscode.executeCompletionItemProvider去触发补全
  • 断言返回的补全项是不是达到了我们的预期

我们再深入一点看看activate(docURI)函数。它被定义在client/src/test/helper.ts中:

  1. import * as vscode from 'vscode';
  2. import * as path from 'path';
  3. export let doc: vscode.TextDocument;
  4. export let editor: vscode.TextEditor;
  5. export let documentEol: string;
  6. export let platformEol: string;
  7. /**
  8. * 激活 vscode.lsp-sample 插件
  9. */
  10. export async function activate(docUri: vscode.Uri) {
  11. // extensionId来自于package.json中的`publisher.name`
  12. const ext = vscode.extensions.getExtension('vscode.lsp-sample');
  13. await ext.activate();
  14. try {
  15. doc = await vscode.workspace.openTextDocument(docUri);
  16. editor = await vscode.window.showTextDocument(doc);
  17. await sleep(2000); // 等待服务器激活
  18. } catch (e) {
  19. console.error(e);
  20. }
  21. }
  22. async function sleep(ms: number) {
  23. return new Promise(resolve => setTimeout(resolve, ms));
  24. }

在激活部分,我们:

  • 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:当文件关闭后调用

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

  1. connection.onInitialize((params): InitializeResult => {
  2. ...
  3. return {
  4. capabilities: {
  5. // 启用文档增量更新同步
  6. textDocumentSync: TextDocumentSyncKind.Incremental,
  7. ...
  8. }
  9. };
  10. });
  11. connection.onDidOpenTextDocument((params) => {
  12. // 当文档打开后触发,params.uri提供了文档的唯一地址。如果文档储存在硬盘上,那么就会是一个file类型的URI
  13. // params.text——提供了文档一开始的内容
  14. });
  15. connection.onDidChangeTextDocument((params) => {
  16. // 文档的文本内容发生了改变时触发。
  17. // params.uri提供了文档的唯一地址。
  18. // params.contentChanges 包含文档的变动内容
  19. });
  20. connection.onDidCloseTextDocument((params) => {
  21. // 文档关闭后触发。
  22. // params.uri提供了文档的唯一地址。
  23. });

直接用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。