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:
- Explains the benefits of Language Server Extension.
- Walks you through building a Language Server using the
Microsoft/vscode-languageserver-node
library. You can also jump directly to the code in lsp-sample.
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!
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.
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:
- lsp-sample: Heavily documented source code for this guide.
- lsp-multi-server-sample: A heavily documented, advanced version of lsp-sample that starts a different server instance per workspace folder to support the multi-root workspace feature in VS Code.
Clone the repository Microsoft/vscode-extension-samples and open the sample:
> git clone https://github.com/microsoft/vscode-extension-samples.git
> cd vscode-extension-samples/lsp-sample
> npm install
> npm run compile
> 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:
.
├── 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
Explaining the 'Language Client'
Let's first take a look at /package.json
, which describes the capabilities of the Language Client. There are two interesting sections:
First, look at the configuration
section:
"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."
}
}
}
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.
Note: If your extension is compatible with VS Code versions prior to 1.74.0, you must declare
onLanguage:plaintext
in theactivationEvents
field of/package.json
to tell VS Code to activate the extension as soon as a plain text file is opened (for example a file with the extension.txt
):"activationEvents": []
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:
"engines": {
"vscode": "^1.52.0"
},
"dependencies": {
"vscode-languageclient": "^7.0.0"
}
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:
import * as path from 'path';
import { workspace, ExtensionContext } from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: ExtensionContext) {
// The server is implemented in node
let serverModule = context.asAbsolutePath(path.join('server', 'out', 'server.js'));
// The debug options for the server
// --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging
let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };
// If the extension is launched in debug mode then the debug server options are used
// Otherwise the run options are used
let serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: debugOptions,
},
};
// Options to control the language client
let clientOptions: LanguageClientOptions = {
// Register the server for plain text documents
documentSelector: [{ scheme: 'file', language: 'plaintext' }],
synchronize: {
// Notify the server about file changes to '.clientrc files contained in the workspace
fileEvents: workspace.createFileSystemWatcher('**/.clientrc'),
},
};
// Create the language client and start the client.
client = new LanguageClient(
'languageServerExample',
'Language Server Example',
serverOptions,
clientOptions
);
// Start the client. This will also launch the server
client.start();
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}
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:
"dependencies": {
"vscode-languageserver": "^7.0.0",
"vscode-languageserver-textdocument": "^1.0.1"
}
This pulls in the vscode-languageserver
libraries.
Below is a server implementation that uses the provided text document manager that synchronizes text documents by always sending incremental deltas from VS Code to the server.
import {
createConnection,
TextDocuments,
Diagnostic,
DiagnosticSeverity,
ProposedFeatures,
InitializeParams,
DidChangeConfigurationNotification,
CompletionItem,
CompletionItemKind,
TextDocumentPositionParams,
TextDocumentSyncKind,
InitializeResult,
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
// Create a connection for the server, using Node's IPC as a transport.
// Also include all preview / proposed LSP features.
let connection = createConnection(ProposedFeatures.all);
// Create a simple text document manager.
let documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
let hasConfigurationCapability: boolean = false;
let hasWorkspaceFolderCapability: boolean = false;
let hasDiagnosticRelatedInformationCapability: boolean = false;
connection.onInitialize((params: InitializeParams) => {
let capabilities = params.capabilities;
// Does the client support the `workspace/configuration` request?
// If not, we fall back using global settings.
hasConfigurationCapability = !!(
capabilities.workspace && !!capabilities.workspace.configuration
);
hasWorkspaceFolderCapability = !!(
capabilities.workspace && !!capabilities.workspace.workspaceFolders
);
hasDiagnosticRelatedInformationCapability = !!(
capabilities.textDocument &&
capabilities.textDocument.publishDiagnostics &&
capabilities.textDocument.publishDiagnostics.relatedInformation
);
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
// Tell the client that this server supports code completion.
completionProvider: {
resolveProvider: true,
},
},
};
if (hasWorkspaceFolderCapability) {
result.capabilities.workspace = {
workspaceFolders: {
supported: true,
},
};
}
return result;
});
connection.onInitialized(() => {
if (hasConfigurationCapability) {
// Register for all configuration changes.
connection.client.register(DidChangeConfigurationNotification.type, undefined);
}
if (hasWorkspaceFolderCapability) {
connection.workspace.onDidChangeWorkspaceFolders((_event) => {
connection.console.log('Workspace folder change event received.');
});
}
});
// The example settings
interface ExampleSettings {
maxNumberOfProblems: number;
}
// The global settings, used when the `workspace/configuration` request is not supported by the client.
// Please note that this is not the case when using this server with the client provided in this example
// but could happen with other clients.
const defaultSettings: ExampleSettings = { maxNumberOfProblems: 1000 };
let globalSettings: ExampleSettings = defaultSettings;
// Cache the settings of all open documents
let documentSettings: Map<string, Thenable<ExampleSettings>> = new Map();
connection.onDidChangeConfiguration((change) => {
if (hasConfigurationCapability) {
// Reset all cached document settings
documentSettings.clear();
} else {
globalSettings = <ExampleSettings>(
(change.settings.languageServerExample || defaultSettings)
);
}
// Revalidate all open text documents
documents.all().forEach(validateTextDocument);
});
function getDocumentSettings(resource: string): Thenable<ExampleSettings> {
if (!hasConfigurationCapability) {
return Promise.resolve(globalSettings);
}
let result = documentSettings.get(resource);
if (!result) {
result = connection.workspace.getConfiguration({
scopeUri: resource,
section: 'languageServerExample',
});
documentSettings.set(resource, result);
}
return result;
}
// Only keep settings for open documents
documents.onDidClose((e) => {
documentSettings.delete(e.document.uri);
});
// The content of a text document has changed. This event is emitted
// when the text document first opened or when its content has changed.
documents.onDidChangeContent((change) => {
validateTextDocument(change.document);
});
async function validateTextDocument(textDocument: TextDocument): Promise<void> {
// In this simple example we get the settings for every validate run.
let settings = await getDocumentSettings(textDocument.uri);
// The validator creates diagnostics for all uppercase words length 2 and more
let text = textDocument.getText();
let pattern = /\b[A-Z]{2,}\b/g;
let m: RegExpExecArray | null;
let problems = 0;
let diagnostics: Diagnostic[] = [];
while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
problems++;
let diagnostic: Diagnostic = {
severity: DiagnosticSeverity.Warning,
range: {
start: textDocument.positionAt(m.index),
end: textDocument.positionAt(m.index + m[0].length),
},
message: `$\{m[0]\}
is all uppercase.`,
source: 'ex',
};
if (hasDiagnosticRelatedInformationCapability) {
diagnostic.relatedInformation = [
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range),
},
message: 'Spelling matters',
},
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range),
},
message: 'Particularly for names',
},
];
}
diagnostics.push(diagnostic);
}
// Send the computed diagnostics to VS Code.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}
connection.onDidChangeWatchedFiles((_change) => {
// Monitored files have change in VS Code
connection.console.log('We received a file change event');
});
// This handler provides the initial list of the completion items.
connection.onCompletion(
(_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
// The pass parameter contains the position of the text document in
// which code complete got requested. For the example we ignore this
// info and always provide the same completion items.
return [
{
label: 'TypeScript',
kind: CompletionItemKind.Text,
data: 1,
},
{
label: 'JavaScript',
kind: CompletionItemKind.Text,
data: 2,
},
];
}
);
// This handler resolves additional information for the item selected in
// the completion list.
connection.onCompletionResolve((item: CompletionItem): CompletionItem => {
if (item.data === 1) {
item.detail = 'TypeScript details';
item.documentation = 'TypeScript documentation';
} else if (item.data === 2) {
item.detail = 'JavaScript details';
item.documentation = 'JavaScript documentation';
}
return item;
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
// Listen on the connection
connection.listen();
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:
// The content of a text document has changed. This event is emitted
// when the text document first opened or when its content has changed.
documents.onDidChangeContent(async (change) => {
let textDocument = change.document;
// In this simple example we get the settings for every validate run.
let settings = await getDocumentSettings(textDocument.uri);
// The validator creates diagnostics for all uppercase words length 2 and more
let text = textDocument.getText();
let pattern = /\b[A-Z]{2,}\b/g;
let m: RegExpExecArray | null;
let problems = 0;
let diagnostics: Diagnostic[] = [];
while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
problems++;
let diagnostic: Diagnostic = {
severity: DiagnosticSeverity.Warning,
range: {
start: textDocument.positionAt(m.index),
end: textDocument.positionAt(m.index + m[0].length),
},
message: `$\{m[0]\}
is all uppercase.`,
source: 'ex',
};
if (hasDiagnosticRelatedInformationCapability) {
diagnostic.relatedInformation = [
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range),
},
message: 'Spelling matters',
},
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range),
},
message: 'Particularly for names',
},
];
}
diagnostics.push(diagnostic);
}
// Send the computed diagnostics to VS Code.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
});
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
kb(workbench.action.tasks.build)
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:
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.
The Extension Development Host instance will then look like this:
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 kb(workbench.action.debug.start)
.
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 and Debug view and select the launch configuration Attach to Server and press kb(workbench.action.debug.start)
. This will attach the debugger to 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:
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:
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;
}
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:
async function validateTextDocument(textDocument: TextDocument): Promise<void> {
// In this simple example we get the settings for every validate run.
let settings = await getDocumentSettings(textDocument.uri);
// The validator creates diagnostics for all uppercase words length 2 and more
let text = textDocument.getText();
let pattern = /\b[A-Z]{2,}\b/g;
let m: RegExpExecArray | null;
let problems = 0;
let diagnostics: Diagnostic[] = [];
while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) {
problems++;
let diagnostic: Diagnostic = {
severity: DiagnosticSeverity.Warning,
range: {
start: textDocument.positionAt(m.index),
end: textDocument.positionAt(m.index + m[0].length),
},
message: `$\{m[0]\}
is all uppercase.`,
source: 'ex',
};
if (hasDiagnosticRelatedInformationCapability) {
diagnostic.relatedInformation = [
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range),
},
message: 'Spelling matters',
},
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range),
},
message: 'Particularly for names',
},
];
}
diagnostics.push(diagnostic);
}
// Send the computed diagnostics to VS Code.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}
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:
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);
});
Starting the client again and changing the setting to maximum report 1 problem results in the following validation: