Language Server Extension Guide

As you have seen in the Programmatic Language Features topic, it’s possible to implement Language Features by directly using languages.* API. Language Server Extension, however, provides an alternative way of implementing such language support.

This topic:

Why Language Server?

Language Server is a special kind of Visual Studio Code extension that powers the editing experience for many programming languages. With Language Servers, you can implement autocomplete, error-checking (diagnostics), jump-to-definition, and many other language features supported in VS Code.

However, while implementing support for language features in VS Code, we found three common problems:

First, Language Servers are usually implemented in their native programming languages, and that presents a challenge in integrating them with VS Code, which has a Node.js runtime.

Additionally, language features can be resource intensive. For example, to correctly validate a file, Language Server needs to parse a large amount of files, build up Abstract Syntax Trees for them and perform static program analysis. Those operations could incur significant CPU and memory usage and we need to ensure that VS Code’s performance remains unaffected.

Finally, integrating multiple language toolings with multiple code editors could involve significant effort. From language toolings’ perspective, they need to adapt to code editors with different APIs. From code editors’ perspective, they cannot expect any uniform API from language toolings. This makes implementing language support for M languages in N code editors the work of M * N.

To solve those problems, Microsoft specified Language Server Protocol, which standardizes the communication between language tooling and code editor. This way, Language Servers can be implemented in any language and run in their own process to avoid performance cost, as they communicate with the code editor through the Language Server Protocol. Furthermore, any LSP-compliant language toolings can integrate with multiple LSP-compliant code editors, and any LSP-compliant code editors can easily pick up multiple LSP-compliant language toolings. LSP is a win for both language tooling providers and code editor vendors!

LSP Languages and Editors

In this guide, we will:

  • Explain how to build a Language Server extension in VS Code using the provided Node SDK.
  • Explain how to run, debug, log, and test the Language Server extension.
  • Point you to some advanced topics on Language Servers.

Implementing a Language Server

Overview

In VS Code, a language server has two parts:

  • Language Client: A normal VS Code extension written in JavaScript / TypeScript. This extension has access to all VS Code Namespace API.
  • Language Server: A language analysis tool running in a separate process.

As briefly stated above there are two benefits of running the Language Server in a separate process:

  • The analysis tool can be implemented in any languages, as long as it can communicate with the Language Client following the Language Server Protocol.
  • As language analysis tools are often heavy on CPU and Memory usage, running them in separate process avoids performance cost.

Here is an illustration of VS Code running two Language Server extensions. The HTML Language Client and PHP Language Client are normal VS Code extensions written in TypeScript. Each of them instantiates a corresponding Language Server and communicates with them through LSP. Although the PHP Language Server is written in PHP, it can still communicate with the PHP Language Client through LSP.

LSP Illustration

This guide will teach you how to build a Language Client / Server using our Node SDK. The remaining document assumes that you are familiar with VS Code Extension API.

LSP Sample - A simple Language Server for plain text files

Let’s build a simple Language Server extension that implements autocomplete and diagnostics for plain text files. We will also cover the syncing of configurations between Client / Server.

If you prefer to jump right into the code:

Clone the repository Microsoft/vscode-extension-samples and open the sample:

  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 .

The above installs all dependencies and opens the lsp-sample workspace containing both the client and server code. Here is a rough overview of the structure of lsp-sample:

  1. .
  2. ├── client // Language Client
  3. ├── src
  4. ├── test // End to End tests for Language Client / Server
  5. └── extension.ts // Language Client entry point
  6. ├── package.json // The extension manifest
  7. └── server // Language Server
  8. └── src
  9. └── server.ts // Language Server entry point

Explaining the ‘Language Client’

Let’s first take a look at /package.json, which describes the capabilities of the Language Client. There are three interesting sections:

First look the activationEvents:

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

This section tells VS Code to activate the extension as soon as a plain text file is opened (for example a file with the extension .txt).

Next look at the configuration section:

  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. }

This section contributes configuration settings to VS Code. The example will explain how these settings are sent over to the language server on startup and on every change of the settings.

The actual Language Client source code and the corresponding package.json are in the /client folder. The interesting part in the /client/package.json file is that it references the vscode extension host API through the engines field and adds a dependency to the vscode-languageclient library:

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

As mentioned, the client is implemented as a normal VS Code extension, and it has access to all VS Code namespace API.

Below is the content of the corresponding extension.ts file, which is the entry of the lsp-sample extension:

  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. // The server is implemented in node
  12. let serverModule = context.asAbsolutePath(path.join('server', 'out', 'server.js'));
  13. // The debug options for the server
  14. // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging
  15. let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };
  16. // If the extension is launched in debug mode then the debug server options are used
  17. // Otherwise the run options are used
  18. let serverOptions: ServerOptions = {
  19. run: { module: serverModule, transport: TransportKind.ipc },
  20. debug: {
  21. module: serverModule,
  22. transport: TransportKind.ipc,
  23. options: debugOptions
  24. }
  25. };
  26. // Options to control the language client
  27. let clientOptions: LanguageClientOptions = {
  28. // Register the server for plain text documents
  29. documentSelector: [{ scheme: 'file', language: 'plaintext' }],
  30. synchronize: {
  31. // Notify the server about file changes to '.clientrc files contained in the workspace
  32. fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
  33. }
  34. };
  35. // Create the language client and start the client.
  36. client = new LanguageClient(
  37. 'languageServerExample',
  38. 'Language Server Example',
  39. serverOptions,
  40. clientOptions
  41. );
  42. // Start the client. This will also launch the server
  43. client.start();
  44. }
  45. export function deactivate(): Thenable<void> | undefined {
  46. if (!client) {
  47. return undefined;
  48. }
  49. return client.stop();
  50. }

Explaining the ‘Language Server’

Note: The ‘Server’ implementation cloned from the GitHub repository has the final walkthrough implementation. To follow the walkthrough, you can create a new server.ts or modify the contents of the cloned version.

In the example, the server is also implemented in TypeScript and executed using Node.js. Since VS Code already ships with a Node.js runtime, there is no need to provide your own, unless you have specific requirements for the runtime.

The source code for the Language Server is at /server. The interesting section in the server’s package.json file is:

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

This pulls in the vscode-languageserver libraries.

Below is a server implementation that uses the provided simple text document manager that synchronizes text documents by always sending the file’s full content from VS Code to the server.

  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. // Create a connection for the server, using Node's IPC as a transport.
  17. // Also include all preview / proposed LSP features.
  18. let connection = createConnection(ProposedFeatures.all);
  19. // Create a simple text document manager.
  20. let documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
  21. let hasConfigurationCapability: boolean = false;
  22. let hasWorkspaceFolderCapability: boolean = false;
  23. let hasDiagnosticRelatedInformationCapability: boolean = false;
  24. connection.onInitialize((params: InitializeParams) => {
  25. let capabilities = params.capabilities;
  26. // Does the client support the `workspace/configuration` request?
  27. // If not, we fall back using global settings.
  28. hasConfigurationCapability = !!(
  29. capabilities.workspace && !!capabilities.workspace.configuration
  30. );
  31. hasWorkspaceFolderCapability = !!(
  32. capabilities.workspace && !!capabilities.workspace.workspaceFolders
  33. );
  34. hasDiagnosticRelatedInformationCapability = !!(
  35. capabilities.textDocument &&
  36. capabilities.textDocument.publishDiagnostics &&
  37. capabilities.textDocument.publishDiagnostics.relatedInformation
  38. );
  39. const result: InitializeResult = {
  40. capabilities: {
  41. textDocumentSync: TextDocumentSyncKind.Incremental,
  42. // Tell the client that this server supports code completion.
  43. completionProvider: {
  44. resolveProvider: true
  45. }
  46. }
  47. };
  48. if (hasWorkspaceFolderCapability) {
  49. result.capabilities.workspace = {
  50. workspaceFolders: {
  51. supported: true
  52. }
  53. };
  54. }
  55. return result;
  56. });
  57. connection.onInitialized(() => {
  58. if (hasConfigurationCapability) {
  59. // Register for all configuration changes.
  60. connection.client.register(DidChangeConfigurationNotification.type, undefined);
  61. }
  62. if (hasWorkspaceFolderCapability) {
  63. connection.workspace.onDidChangeWorkspaceFolders(_event => {
  64. connection.console.log('Workspace folder change event received.');
  65. });
  66. }
  67. });
  68. // The example settings
  69. interface ExampleSettings {
  70. maxNumberOfProblems: number;
  71. }
  72. // The global settings, used when the `workspace/configuration` request is not supported by the client.
  73. // Please note that this is not the case when using this server with the client provided in this example
  74. // but could happen with other clients.
  75. const defaultSettings: ExampleSettings = { maxNumberOfProblems: 1000 };
  76. let globalSettings: ExampleSettings = defaultSettings;
  77. // Cache the settings of all open documents
  78. let documentSettings: Map<string, Thenable<ExampleSettings>> = new Map();
  79. connection.onDidChangeConfiguration(change => {
  80. if (hasConfigurationCapability) {
  81. // Reset all cached document settings
  82. documentSettings.clear();
  83. } else {
  84. globalSettings = <ExampleSettings>(
  85. (change.settings.languageServerExample || defaultSettings)
  86. );
  87. }
  88. // Revalidate all open text documents
  89. documents.all().forEach(validateTextDocument);
  90. });
  91. function getDocumentSettings(resource: string): Thenable<ExampleSettings> {
  92. if (!hasConfigurationCapability) {
  93. return Promise.resolve(globalSettings);
  94. }
  95. let result = documentSettings.get(resource);
  96. if (!result) {
  97. result = connection.workspace.getConfiguration({
  98. scopeUri: resource,
  99. section: 'languageServerExample'
  100. });
  101. documentSettings.set(resource, result);
  102. }
  103. return result;
  104. }
  105. // Only keep settings for open documents
  106. documents.onDidClose(e => {
  107. documentSettings.delete(e.document.uri);
  108. });
  109. // The content of a text document has changed. This event is emitted
  110. // when the text document first opened or when its content has changed.
  111. documents.onDidChangeContent(change => {
  112. validateTextDocument(change.document);
  113. });
  114. async function validateTextDocument(textDocument: TextDocument): Promise<void> {
  115. // In this simple example we get the settings for every validate run.
  116. let settings = await getDocumentSettings(textDocument.uri);
  117. // The validator creates diagnostics for all uppercase words length 2 and more
  118. let text = textDocument.getText();
  119. let pattern = /\b[A-Z]{2,}\b/g;
  120. let m: RegExpExecArray | null;
  121. let problems = 0;
  122. let diagnostics: Diagnostic[] = [];
  123. while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
  124. problems++;
  125. let diagnostic: Diagnostic = {
  126. severity: DiagnosticSeverity.Warning,
  127. range: {
  128. start: textDocument.positionAt(m.index),
  129. end: textDocument.positionAt(m.index + m[0].length)
  130. },
  131. message: `${m[0]} is all uppercase.`,
  132. source: 'ex'
  133. };
  134. if (hasDiagnosticRelatedInformationCapability) {
  135. diagnostic.relatedInformation = [
  136. {
  137. location: {
  138. uri: textDocument.uri,
  139. range: Object.assign({}, diagnostic.range)
  140. },
  141. message: 'Spelling matters'
  142. },
  143. {
  144. location: {
  145. uri: textDocument.uri,
  146. range: Object.assign({}, diagnostic.range)
  147. },
  148. message: 'Particularly for names'
  149. }
  150. ];
  151. }
  152. diagnostics.push(diagnostic);
  153. }
  154. // Send the computed diagnostics to VS Code.
  155. connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
  156. }
  157. connection.onDidChangeWatchedFiles(_change => {
  158. // Monitored files have change in VS Code
  159. connection.console.log('We received a file change event');
  160. });
  161. // This handler provides the initial list of the completion items.
  162. connection.onCompletion(
  163. (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
  164. // The pass parameter contains the position of the text document in
  165. // which code complete got requested. For the example we ignore this
  166. // info and always provide the same completion items.
  167. return [
  168. {
  169. label: 'TypeScript',
  170. kind: CompletionItemKind.Text,
  171. data: 1
  172. },
  173. {
  174. label: 'JavaScript',
  175. kind: CompletionItemKind.Text,
  176. data: 2
  177. }
  178. ];
  179. }
  180. );
  181. // This handler resolves additional information for the item selected in
  182. // the completion list.
  183. connection.onCompletionResolve(
  184. (item: CompletionItem): CompletionItem => {
  185. if (item.data === 1) {
  186. item.detail = 'TypeScript details';
  187. item.documentation = 'TypeScript documentation';
  188. } else if (item.data === 2) {
  189. item.detail = 'JavaScript details';
  190. item.documentation = 'JavaScript documentation';
  191. }
  192. return item;
  193. }
  194. );
  195. // Make the text document manager listen on the connection
  196. // for open, change and close text document events
  197. documents.listen(connection);
  198. // Listen on the connection
  199. connection.listen();

Adding a Simple Validation

To add document validation to the server, we add a listener to the text document manager that gets called whenever the content of a text document changes. It is then up to the server to decide when the best time is to validate a document. In the example implementation, the server validates the plain text document and flags all occurrences of words that use ALL CAPS. The corresponding code snippet looks like this:

  1. // The content of a text document has changed. This event is emitted
  2. // when the text document first opened or when its content has changed.
  3. documents.onDidChangeContent(async change => {
  4. let textDocument = change.document;
  5. // In this simple example we get the settings for every validate run.
  6. let settings = await getDocumentSettings(textDocument.uri);
  7. // The validator creates diagnostics for all uppercase words length 2 and more
  8. let text = textDocument.getText();
  9. let pattern = /\b[A-Z]{2,}\b/g;
  10. let m: RegExpExecArray | null;
  11. let problems = 0;
  12. let diagnostics: Diagnostic[] = [];
  13. while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
  14. problems++;
  15. let diagnostic: Diagnostic = {
  16. severity: DiagnosticSeverity.Warning,
  17. range: {
  18. start: textDocument.positionAt(m.index),
  19. end: textDocument.positionAt(m.index + m[0].length)
  20. },
  21. message: `${m[0]} is all uppercase.`,
  22. source: 'ex'
  23. };
  24. if (hasDiagnosticRelatedInformationCapability) {
  25. diagnostic.relatedInformation = [
  26. {
  27. location: {
  28. uri: textDocument.uri,
  29. range: Object.assign({}, diagnostic.range)
  30. },
  31. message: 'Spelling matters'
  32. },
  33. {
  34. location: {
  35. uri: textDocument.uri,
  36. range: Object.assign({}, diagnostic.range)
  37. },
  38. message: 'Particularly for names'
  39. }
  40. ];
  41. }
  42. diagnostics.push(diagnostic);
  43. }
  44. // Send the computed diagnostics to VS Code.
  45. connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
  46. });

Diagnostics Tips and Tricks

  • If the start and end positions are the same, VS Code will underline with a squiggle the word at that position.
  • If you want to underline with a squiggle until the end of the line, then set the character of the end position to Number.MAX_VALUE.

To run the Language Server, do the following steps:

  • Press ⇧⌘B (Windows, Linux Ctrl+Shift+B) to start the build task. The task compiles both the client and the server.
  • Open the Run view, select the Launch Client launch configuration, and press the Start Debugging button to launch an additional Extension Development Host instance of VS Code that executes the extension code.
  • Create a test.txt file in the root folder and paste the following content:
  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.

The Extension Development Host instance will then look like this:

Validating a text file

Debugging both Client and Server

Debugging the client code is as easy as debugging a normal extension. Set a breakpoint in the client code and debug the extension by pressing F5.

Debugging the client

Since the server is started by the LanguageClient running in the extension (client), we need to attach a debugger to the running server. To do so, switch to the Run view and select the launch configuration Attach to Server and press F5. This will attach the debugger to the server.

Debugging the server

Logging Support for Language Server

If you are using vscode-languageclient to implement the client, you can specify a setting [langId].trace.server that instructs the Client to log communications between Language Client / Server to a channel of the Language Client’s name.

For lsp-sample, you can set this setting: "languageServerExample.trace.server": "verbose". Now head to the channel “Language Server Example”. You should see the logs:

LSP Log

As Language Servers can be chatty (5 seconds of real-world usage can produce 5000 lines of log), we also provide a tool to visualize and filter the communication between Language Client / Server. You can save all logs from the channel into a file, and load that file with the Language Server Protocol Inspector at https://microsoft.github.io/language-server-protocol/inspector.

LSP Inspector

Using Configuration Settings in the Server

When writing the client part of the extension, we already defined a setting to control the maximum numbers of problems reported. We also wrote code on the server side to read these settings from the client:

  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. }

The only thing we need to do now is to listen to configuration changes on the server side and if a setting changes, revalidate the open text documents. To be able to reuse the validate logic of the document change event handling, we extract the code into a validateTextDocument function and modify the code to honor a maxNumberOfProblems variable:

  1. async function validateTextDocument(textDocument: TextDocument): Promise<void> {
  2. // In this simple example we get the settings for every validate run.
  3. let settings = await getDocumentSettings(textDocument.uri);
  4. // The validator creates diagnostics for all uppercase words length 2 and more
  5. let text = textDocument.getText();
  6. let pattern = /\b[A-Z]{2,}\b/g;
  7. let m: RegExpExecArray | null;
  8. let problems = 0;
  9. let diagnostics: Diagnostic[] = [];
  10. while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
  11. problems++;
  12. let diagnostic: 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. diagnostic.relatedInformation = [
  23. {
  24. location: {
  25. uri: textDocument.uri,
  26. range: Object.assign({}, diagnostic.range)
  27. },
  28. message: 'Spelling matters'
  29. },
  30. {
  31. location: {
  32. uri: textDocument.uri,
  33. range: Object.assign({}, diagnostic.range)
  34. },
  35. message: 'Particularly for names'
  36. }
  37. ];
  38. }
  39. diagnostics.push(diagnostic);
  40. }
  41. // Send the computed diagnostics to VS Code.
  42. connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
  43. }

The handling of the configuration change is done by adding a notification handler for configuration changes to the connection. The corresponding code looks like this:

  1. connection.onDidChangeConfiguration(change => {
  2. if (hasConfigurationCapability) {
  3. // Reset all cached document settings
  4. documentSettings.clear();
  5. } else {
  6. globalSettings = <ExampleSettings>(
  7. (change.settings.languageServerExample || defaultSettings)
  8. );
  9. }
  10. // Revalidate all open text documents
  11. documents.all().forEach(validateTextDocument);
  12. });

Starting the client again and changing the setting to maximum report 1 problem results in the following validation:

Maximum One Problem

Adding additional Language Features

The first interesting feature a language server usually implements is validation of documents. In that sense, even a linter counts as a language server and in VS Code linters are usually implemented as language servers (see eslint and jshint for examples). But there is more to language servers. They can provide code completion, Find All References, or Go To Definition. The example code below adds code completion to the server. It proposes the two words ‘TypeScript’ and ‘JavaScript’.

  1. // This handler provides the initial list of the completion items.
  2. connection.onCompletion(
  3. (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
  4. // The pass parameter contains the position of the text document in
  5. // which code complete got requested. For the example we ignore this
  6. // info and always provide the same completion items.
  7. return [
  8. {
  9. label: 'TypeScript',
  10. kind: CompletionItemKind.Text,
  11. data: 1
  12. },
  13. {
  14. label: 'JavaScript',
  15. kind: CompletionItemKind.Text,
  16. data: 2
  17. }
  18. ];
  19. }
  20. );
  21. // This handler resolves additional information for the item selected in
  22. // the completion list.
  23. connection.onCompletionResolve(
  24. (item: CompletionItem): CompletionItem => {
  25. if (item.data === 1) {
  26. item.detail = 'TypeScript details';
  27. item.documentation = 'TypeScript documentation';
  28. } else if (item.data === 2) {
  29. item.detail = 'JavaScript details';
  30. item.documentation = 'JavaScript documentation';
  31. }
  32. return item;
  33. }
  34. );

The data fields are used to uniquely identify a completion item in the resolve handler. The data property is transparent for the protocol. Since the underlying message passing protocol is JSON-based, the data field should only hold data that is serializable to and from JSON.

All that is missing is to tell VS Code that the server supports code completion requests. To do so, flag the corresponding capability in the initialize handler:

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

The screenshot below shows the completed code running on a plain text file:

Code Complete

Testing The Language Server

To create a high-quality Language Server, we need to build a good test suite covering its functionalities. There are two common ways of testing Language Servers:

  • Unit Test: This is useful if you want to test specific functionalities in Language Servers by mocking up all the information being sent to it. VS Code’s HTML / CSS / JSON Language Servers take this approach to testing. The LSP npm modules also use this approach. See here for some unit test written using the npm protocol module.
  • End-to-End Test: This is similar to VS Code extension test. The benefit of this approach is that it runs the test by instantiating a VS Code instance with a workspace, opening the file, activating the Language Client / Server, and running VS Code commands. This approach is superior if you have files, settings, or dependencies (such as node_modules) which are hard or impossible to mock. The popular Python extension takes this approach to testing.

It is possible to do Unit Test in any testing framework of your choice. Here we describe how to do End-to-End testing for Language Server Extension.

Open .vscode/launch.json, and you can find a E2E test target:

  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/index",
  9. "${workspaceRoot}/client/testFixture"
  10. ],
  11. "outFiles": ["${workspaceRoot}/client/out/test/**/*.js"]
  12. }

If you run this debug target, it will launch a VS Code instance with client/testFixture as the active workspace. VS Code will then proceed to execute all tests in client/src/test. As a debugging tip, you can set breakpoints in TypeScript files in client/src/test and they will be hit.

Let’s take a look at the completion.test.ts file:

  1. import * as vscode from 'vscode';
  2. import * as assert from 'assert';
  3. import { getDocUri, activate } from './helper';
  4. suite('Should do completion', () => {
  5. const docUri = getDocUri('completion.txt');
  6. test('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. // Executing the command `vscode.executeCompletionItemProvider` to simulate triggering completion
  22. const actualCompletionList = (await vscode.commands.executeCommand(
  23. 'vscode.executeCompletionItemProvider',
  24. docUri,
  25. position
  26. )) as vscode.CompletionList;
  27. assert.ok(actualCompletionList.items.length >= 2);
  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. }

In this test, we:

  • Activate the extension.
  • Run the command vscode.executeCompletionItemProvider with a URI and a position to simulate completion trigger.
  • Assert the returned completion items against our expected completion items.

Let’s dive a bit deeper into the activate(docURI) function. It is defined in 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. * Activates the vscode.lsp-sample extension
  9. */
  10. export async function activate(docUri: vscode.Uri) {
  11. // The extensionId is `publisher.name` from package.json
  12. const ext = vscode.extensions.getExtension('vscode-samples.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); // Wait for server activation
  18. } catch (e) {
  19. console.error(e);
  20. }
  21. }
  22. async function sleep(ms: number) {
  23. return new Promise(resolve => setTimeout(resolve, ms));
  24. }

In the activation part, we:

  • Get the extension using the {publisher.name}.{extensionId}, as defined in package.json.
  • Open the specified document, and show it in the active text editor.
  • Sleep for 2 seconds, so we are sure the Language Server is instantiated.

After the preparation, we can run the VS Code Commands corresponding to each language feature, and assert against the returned result.

There is one more test that covers the diagnostics feature that we just implemented. Check it out at client/src/test/diagnostics.test.ts.

Advanced Topics

So far, this guide covered:

  • A brief overview of Language Server and Language Server Protocol.
  • Architecture of a Language Server extension in VS Code
  • The lsp-sample extension, and how to develop/debug/inspect/test it.

There are some more advanced topics we could not fit in to this guide. We will include links to these resources for further studying of Language Server development.

Additional Language Server features

The following language features are currently supported in a language server along with code completions:

  • Document Highlights: highlights all ‘equal’ symbols in a text document.
  • Hover: provides hover information for a symbol selected in a text document.
  • Signature Help: provides signature help for a symbol selected in a text document.
  • Goto Definition: provides go to definition support for a symbol selected in a text document.
  • Goto Type Definition: provides go to type/interface definition support for a symbol selected in a text document.
  • Goto Implementation: provides go to implementation definition support for a symbol selected in a text document.
  • Find References: finds all project-wide references for a symbol selected in a text document.
  • List Document Symbols: lists all symbols defined in a text document.
  • List Workspace Symbols: lists all project-wide symbols.
  • Code Actions: compute commands to run (typically beautify/refactor) for a given text document and range.
  • CodeLens: compute CodeLens statistics for a given text document.
  • Document Formatting: this includes formatting of whole documents, document ranges and formatting on type.
  • Rename: project-wide rename of a symbol.
  • Document Links: compute and resolve links inside a document.
  • Document Colors: compute and resolve colors inside a document to provide color picker in editor.

The Programmatic Language Features topic describes each of the language features above and provides guidance on how to implement them either through the language server protocol or by using the extensibility API directly from your extension.

Incremental Text Document Synchronization

The example uses the simple text document manager provided by the vscode-languageserver module to synchronize documents between VS Code and the language server.

This has two drawbacks:

  • Lots of data is transferred since the whole content of a text document is sent to the server repeatedly.
  • If an existing language library is used, such libraries usually support incremental document updates to avoid unnecessary parsing and abstract syntax tree creation.

The protocol therefore supports incremental document synchronization as well.

To make use of incremental document synchronization, a server needs to install three notification handlers:

  • onDidOpenTextDocument: is called when a text document is opened in VS Code.
  • onDidChangeTextDocument: is called when the content of a text document changes in VS Code.
  • onDidCloseTextDocument: is called when a text document is closed in VS Code.

Below is a code snippet that illustrates how to hook these notification handlers on a connection and how to return the right capability on initialize:

  1. connection.onInitialize((params): InitializeResult => {
  2. ...
  3. return {
  4. capabilities: {
  5. // Enable incremental document sync
  6. textDocumentSync: TextDocumentSyncKind.Incremental,
  7. ...
  8. }
  9. };
  10. });
  11. connection.onDidOpenTextDocument((params) => {
  12. // A text document was opened in VS Code.
  13. // params.uri uniquely identifies the document. For documents stored on disk, this is a file URI.
  14. // params.text the initial full content of the document.
  15. });
  16. connection.onDidChangeTextDocument((params) => {
  17. // The content of a text document has change in VS Code.
  18. // params.uri uniquely identifies the document.
  19. // params.contentChanges describe the content changes to the document.
  20. });
  21. connection.onDidCloseTextDocument((params) => {
  22. // A text document was closed in VS Code.
  23. // params.uri uniquely identifies the document.
  24. });

Using VS Code API directly to implement Language Features

While Language Servers have many benefits, they are not the only option for extending the editing capabilities of VS Code. In the cases when you want to add some simple language features for a type of document, consider using vscode.languages.register[LANGUAGE_FEATURE]Provider as an option.

Here is a completions-sample using vscode.languages.registerCompletionItemProvider to add a few snippets as completions for plain text files.

More samples illustrating the usage of VS Code API can be found at https://github.com/microsoft/vscode-extension-samples.

Error Tolerant Parser for Language Server

Most of the time, the code in the editor is incomplete and syntactically incorrect, but developers would still expect autocomplete and other language features to work. Therefore, an error tolerant parser is necessary for a Language Server: The parser generates meaningful AST from partially complete code, and the Language Server provides language features based on the AST.

When we were improving PHP support in VS Code, we realized the official PHP parser is not error tolerant and cannot be reused directly in the Language Server. Therefore, we worked on Microsoft/tolerant-php-parser and left detailed notes that might help Language Server authors who need to implement an error tolerant parser.

Common questions

When I try to attach to the server, I get “cannot connect to runtime process (timeout after 5000 ms)”?

You will see this timeout error if the server isn’t running when you try to attach the debugger. The client starts the language server so make sure you have started the client in order to have a running server. You may also need to disable your client breakpoints if they are interfering with starting the server.

I have read through this guide and the LSP Specification, but I still have unresolved questions. Where can I get help?

Please open an issue at https://github.com/microsoft/language-server-protocol.