Embedded Programming Languages
Visual Studio Code provides rich language features for programming languages. As you have read in the Language Server extension guide, you can write language servers to support any programming language. However, it involves more effort to enable such support for embedded languages.
Today, there are an increasing number of embedded languages, such as:
- JavaScript and CSS in HTML
- JSX in JavaScript
- Interpolation in templating languages, for example Vue, Handlebars and Razor
- HTML in PHP
This guide focuses on implementing language features for embedded languages. If you are interested in providing syntax highlighting for embedded languages, you can find information in the Syntax Highlight guide.
This guide includes two samples that illustrate two approaches to build such a language server: Language Services and Request Forwarding. We'll review both samples and conclude with each approach's pros and cons.
Source code for both samples can be found at:
- Language Server for Embedded Language with Language Services
- Language Server for Embedded Language with Request Forwarding
Here's the embedded language server we'll be building:
Both samples contribute a new language, html1
, for illustration purpose. You can create a file .html1
and test the following functionalities:
- Completions for HTML tags
- Completions for CSS in
tag
- Diagnostics for CSS (only in the Language Services sample)
Language Services
A language service is a library that implements programmatic language features for a single language. A language server can embed language services to handle embedded languages.
Here's an outline of VS Code's HTML support:
- The built-in html extension only provides syntax highlighting and language configuration for HTML.
- The built-in html-language-features extension includes an HTML Language Server to offer programmatic language features for HTML.
- The HTML Language Server uses vscode-html-languageservice to support HTML.
- The CSS Language Server uses vscode-css-languageservice to support CSS in HTML.
The HTML language server analyzes an HTML document, breaks it down into language regions, and uses the corresponding language service to handle language server requests.
For example:
- For auto-completion request at
<|
, the HTML language server uses the HTML language service to provide HTML completions. - For auto-completion request at
, the HTML language server uses the CSS language service to provide CSS completions.
Let's examine the lsp-embedded-language-service sample, a simplified version of the HTML language server that implements auto-completion for HTML and CSS, and diagnostic errors for CSS.
Language Services sample
Note: This sample assumes knowledge of the Programmatic Language Features topic and the Language Server extension guide. The code builds on top of lsp-sample.
The source code is available at microsoft/vscode-extension-samples.
Compared to the lsp-sample, the client-side code is the same.
As mentioned above, the server breaks down the document into different language regions to handle the embedded content.
Here is a simple example:
<div>div>
<style>.foo { }style>
In this case, the server detects the tag, and marks
.foo { }
as a CSS region.
Given an auto completion request at a specific position, the server uses the following logic to compute a response:
- If the position falls into any region
- Handle it with a virtual document with the region's language, while replacing all other regions with whitespace
- If the position falls out of any region
- Handle it with a virtual document in HTML, while replacing all regions with whitespace
For example, when doing an auto completion in this position:
<div>div>
<style>.foo { | }style>
The server determines that the position is inside the region and computes a virtual CSS document with the following content (█ stands for space)):
███████████
███████.foo { | }████████
The server then uses vscode-css-languageservice
to analyze this document and compute a list of completion items. Because the content now contains no HTML, the CSS language service can handle it without issue. By replacing all non-CSS content with whitespace, we save ourselves from having to manually offset the positions.
The server code handling completion requests:
connection.onCompletion(async (textDocumentPosition, token) => {
const document = documents.get(textDocumentPosition.textDocument.uri);
if (!document) {
return null;
}
const mode = languageModes.getModeAtPosition(document, textDocumentPosition.position);
if (!mode || !mode.doComplete) {
return CompletionList.create();
}
const doComplete = mode.doComplete!;
return doComplete(document, textDocumentPosition.position);
});
The CSS mode that is responsible for handling all language server requests that fall into CSS regions:
export function getCSSMode(
cssLanguageService: CSSLanguageService,
documentRegions: LanguageModelCache<HTMLDocumentRegions>
): LanguageMode {
return {
getId() {
return 'css';
},
doComplete(document: TextDocument, position: Position) {
// Get virtual CSS document, with all non-CSS code replaced with whitespace
const embedded = documentRegions.get(document).getEmbeddedDocument('css');
// Compute a response with vscode-css-languageservice
const stylesheet = cssLanguageService.parseStylesheet(embedded);
return cssLanguageService.doComplete(embedded, position, stylesheet);
}
};
}
This is a simple and effective approach for handling embedded languages. However, there are some drawbacks with this approach:
- You have to continuously update the language services that your language server depends on.
- It can be challenging to include language services that are not written in the same language as your language server. For example, a PHP language server written in PHP would find it cumbersome to include the
vscode-css-languageservice
written in TypeScript.
We'll now cover request forwarding, which would solve the problems above.
Request Forwarding
In a nutshell, request forwarding works in a similar way as language services. The request forwarding approach also takes language server requests, computes virtual content, and calculates the responses.
The major differences are:
- While the language service approach uses libraries to calculate language server responses, request forwarding sends the request back to VS Code to use extensions that are active and have registered a completion provider for the embedded language.
Here is the simple example again:
<div>div>
<style>.foo { | }style>
Auto completion happens in this way:
- The language client registers a virtual text document provider for
embedded-content
document usingworkspace.registerTextDocumentContentProvider
. - The language client hijacks completion requests for
. - The language client determines that the request position falls into a CSS region.
- The language client constructs a new URI, such as
embedded-content://css/
..css - The language client then calls
commands.executeCommand('vscode.executeCompletionItemProvider', ...)
- VS Code's CSS language server responds to this provider request.
- The virtual text document provider provides CSS language server with virtual content, where all non-CSS code is replaced with whitespace.
- The language client receives response from VS Code and sends it as the response.
With this approach, we are able to compute CSS auto-completion even if our code does not include any library that understands CSS. As VS Code updates its CSS language server, we get the latest CSS language support without having to update our code.
Let's now review the sample code.
Request Forwarding sample
Note: This sample assumes knowledge of the Programmatic Language Features topic and the Language Server extension guide. The code builds on top of lsp-sample.
The source code is available at microsoft/vscode-extension-samples.
Keeping a map between document's URI and their virtual documents, and provide them for corresponding requests:
const virtualDocumentContents = new Map<string, string>();
workspace.registerTextDocumentContentProvider('embedded-content', {
provideTextDocumentContent: uri => {
// Remove leading `/` and ending `.css` to get original URI
const originalUri = uri.path.slice(1).slice(0, -4);
const decodedUri = decodeURIComponent(originalUri);
return virtualDocumentContents.get(decodedUri);
}
});
By using the middleware
option of language client, we hijack request for auto completion:
let clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: 'file', language: 'html' }],
middleware: {
provideCompletionItem: async (document, position, context, token, next) => {
// If not in `