This project offers a small language server for
.vscode/extensions.json
files.
This README.md
is written as a tutorial in which I'll explain how the @donaldpipowitch/vscode-extension-*
project was created. This should be helpful, if you want to create a similar project or if you want to contribute to this project.
If you're just interested in using the @donaldpipowitch/vscode-extension-*
packages, you'll find the usage information in their corresponding README.md
's.
Package | Description |
---|---|
@donaldpipowitch/vscode-extension-core |
Exports some useful APIs to get information about VS Code extensions. |
@donaldpipowitch/vscode-extension-server |
A .vscode/extensions.json language server. |
vscode-extensions-files |
A client for the .vscode/extensions.json language server. The client is a VS Code extension. |
π‘ In case you are wondering about the package name for the VS Code extension: VS Code extensions aren't published to npm, but to the Visual Studio Code Marketplace. They slightly differ from your usual package (and don't allow scoped package names for example). Luckily you'll learn more about that in this tutorial.
If you feel stuck with my tutorial, just open an issue or write me on Twitter. If you have general questions about authoring VS Code extension you can visit the #vscode-extension-dev channel on slack, which helped me several times. It can also be useful to create an issue or pull request in some of the VS Code related repositories like vscode-extension-samples, if your issue is related.
- Background
- Goal of this language server
- Initial setup
- Basic project structure
- Creating
@donaldpipowitch/vscode-extension-core
and add search - Creating
@donaldpipowitch/vscode-extension-server
and add code completion - Creating
vscode-extensions-files
and test everything - Our first release
- TODOs
In this article you will learn how you can create a language server and a VS Code extension which uses this language server. A language server adds features like autocomplete, go to definition or documentation on hover to a programming language, to domain specific languages, but also to frameworks and configuration files, if it can't be covered by the underlying language alone.
VS Code is a nice and extensible open source editor. We'll create an extension for VS Code which will use our custom language server. While language servers are editor agnostic we'll create a language server for a VS Code specific feature. So in this case it only makes sense to use it in the context of VS Code.
There are already existing tutorials which cover this topic. The best tutorial probably was created by the Microsoft team itself which is responsible for VS Code and the language server protocol which powers all language servers. You can find Microsofts tutorial here. Nevertheless I write my own tutorial for two reasons. First I write this tutorial for myself, so I can learn the concepts and the APIs in my own pace. Second I write it for you, because sometimes it helps to get a similar tutorial for the same topic from a different perspective. For example my tooling, my project structure and my writing style will be slightly different. And sometimes this already helps learning something new!
The language server we'll create and the corresponding VS Code extension will add some nice functionality to .vscode/extensions.json
files. If you don't know them, no worries. I'll explain them in the next section.
I expect you to have some intermediate knowledge in Node development. This project will be written in TypeScript, we use Jest for testing and yarn as our package manager.
.vscode/extensions.json
files in the root of a project are VS Code specific configuration files. They can contain recommendations for extensions which should be used in this projects as well as recommendations of extensions which should not be used. If a user of VS Code opens the project the editor asks the user if he/she wants to install missing recommended extensions or to disable unwanted, but already installed extensions.
Out of the box VS Code already offers code completion and validation for the interface ({ recommendations: string[], unwantedRecommendations: string[] }
) of these files. The code completion for recommendations[]
/unwantedRecommendations[]
even shows you currently installed extensions. What is missing?
- code completion for extensions which aren't installed locally
- on hover documentation for an extension (TODO)
- go to definitions for an extension (TODO)
We try to add these three features in this tutorial.
The project was tested and developed with following technologies:
- VS Code (I used
1.30.0-insider
) - Node (I used
8.14.0
- VS Code Insiders just switched tonode@10
on Dec. 13, 2018) - yarn (I used
1.12.3
) - npm (I used
6.4.1
- it is explained in the article, why I use yarn and npm) - Git (I used
2.18.0
)
If you have these requirements installed, you can setup the project with the following steps:
$ git clone https://github.com/donaldpipowitch/how-to-create-a-language-server-and-vscode-extension.git
$ cd how-to-create-a-language-server-and-vscode-extension
$ yarn
Before we dive into one of our packages I'll give you a short overview about the whole project structure.
README.md
: The very file you're currently reading. It serves as a tutorial for the whole project.LICENSE
: This project uses the MIT license..vscode/extensions.json
: This project has a VS Extension recommendation, too. See the following file:prettier.config.js
: We use Prettier for code formatting and this is the corresponding config file..prettierignore
: With this file Prettier will not format generated files..gitignore
: We ignore dependencies and meta data/generated files in Git. See here to learn more.package.json
: This file contains our workspace configuration, because our projects contains multiple packages. It also contains top-level dependencies and commands likebuild
andlint
. (Thelint
command will run Prettier.).travis.yml
: This is the config file for Travis, our CI system. We'll build, lint and test our code on every commit. You can find our CI logs here..vscode/settings.json
: This file contains some shared VS Code configs. You'll get these settings automatically, if you open this project with VS Code..vscode/launch.json
: This file contains some script/launch configurations which we'll need later on for debugging purposes. I'll explain this in more detail later in the article.tsconfig.base.json
: This file contains our shared TypeScript configs. I just want to point out, that I always try to usestrict: true
for better type safety and"types": []
to not load every@types/*
package by default (to avoid having@types/jest
interfaces available in my non-test files for example).yarn.lock
: This file contains the last known working versions of our dependencies. Read here to learn more.
Our configurations and meta files out of the way we'll have a look into the packages/
directory. This directory contains all the packages which we briefly explained above:
packages/core
: This directory contains@donaldpipowitch/vscode-extension-core
.packages/server
: Here we can find@donaldpipowitch/vscode-extension-server
.packages/client
: Last, but not least -vscode-extensions-files
.
Maybe you are wondering why we have a core
package and not just the server and the client (which is our extension)? Many frameworks and tools add language server on top of their original functionality. Think of ESLint (https://eslint.org/) which works standalone from the ESLint language server (https://github.com/Microsoft/vscode-eslint/blob/master/server). We do the same. This is useful so others can build on top of our logic - but without the need to load language server specific dependencies. This could be useful for small libs and CLIs. Besides that it makes it easier to show you which part of code is actually language server specific and which not.
Let's start with our core
package.
The core package exports a function called search
which takes a search value to look for VS Code extensions. We actually use the Visual Studio MarketPlace API here, which is not public and could break at any time (not just my words). You normally probably wouldn't want to rely on this, but for the sake of a tutorial it should be fine.
We'll use axios
to make requests against the MarketPlace API and we want the caller of our search
function to be able to cancel our request, so we'll return not just an awaitable request
object (which fulfills to an array of extensions on success), but also a cancel
method. If the caller calls cancel
the request
will be fulfilled as undefined
. All in all the request should be relatively straightforward if you used axios
before.
This is our src/search.ts
:
import axios, { Canceler } from 'axios';
export { Canceler };
/**
* In the `.vscode/extensions.json` we'll need to use `${publisher.publisherName}.${extensionName}`.
*
* @example
* {
* "publisher": {
* "publisherId": "d16f4e39-2ffb-44e3-9c0d-79d873570e3a",
* "publisherName": "esbenp",
* "displayName": "Esben Petersen",
* "flags": "none"
* },
* "extensionId": "96fa4707-6983-4489-b7c5-d5ffdfdcce90",
* "extensionName": "prettier-vscode",
* "displayName": "Prettier - Code formatter",
* "flags": "validated, public",
* "lastUpdated": "2018-08-09T12:05:04.413Z",
* "publishedDate": "2017-01-10T19:52:02.703Z",
* "releaseDate": "2017-01-10T19:52:02.703Z",
* "shortDescription": "VS Code plugin for prettier/prettier",
* "deploymentType": 0
* }
*/
export type Extension = {
publisher: {
publisherId: string;
publisherName: string;
displayName: string;
flags: string;
};
extensionId: string;
extensionName: string;
displayName: string;
flags: string;
lastUpdated: string;
publishedDate: string;
releaseDate: string;
shortDescription: string;
deploymentType: number;
};
export type SearchRequest = {
cancel: Canceler;
request: Promise<Extension[] | void>;
};
export function search(value: string): SearchRequest {
const { token, cancel } = axios.CancelToken.source();
const options = {
cancelToken: token,
url:
'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery',
method: 'post',
headers: {
accept: 'application/json;api-version=5.0-preview.1;excludeUrls=true'
},
data: {
filters: [
{
criteria: [
// which visual studio app? code
{ filterType: 8, value: 'Microsoft.VisualStudio.Code' },
// our search value
{ filterType: 10, value }
],
pageSize: 10,
pageNumber: 1
}
]
}
};
const request = axios(options)
.then(({ data }) => data.results[0].extensions as Extension[])
.catch((error) => {
if (!axios.isCancel(error)) {
throw error;
}
});
return { request, cancel };
}
Our src/index.ts
just takes care of re-exporting this:
export * from './search';
For the sake of completeness we also have src/tsconfig.json
which extends from our tsconfig.base.json
in our project root and takes care of setting our output directory.
Our package.json
is also quite straightforward as it doesn't contain any language server specific metadata. The package can be build by calling $ yarn build
or $ yarn watch
.
You can easily test our package, if you build our package, run node
inside packages/core
and try out our package like this (and press Ctrl+C
twice at the end to exit again):
$ cd packages/core
$ yarn build
$ node
> require('./dist').search('prettier').request.then(console.log)
This requires our package, calls search
with the value 'prettier'
and console.log
's the result.
I'll also add some small unit tests. We'll use Jest as our testing framework. Together with the ts-jest
our Jest config in tests/jest.config.js
is quite small. We just configured testMatch
to treat every .ts
file inside tests/
as a test file and we configured testPathIgnorePatterns
to exclude the __fixture__
directory. (I use fixtures in a similar way as explained in this article. For me a fixture is just some static data, so I haven't put it into the typicals __mocks__
directory, because I don't mock the implementation of some module here, which is how mocks are usually defined in Jest.) Note that we also have a tests/tsconfig.json
so we can add Jest type declarations to our tests.
This is our test for the search API:
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { search } from '../src/search';
import { prettier } from './__fixtures__/search-response';
const mock = new MockAdapter(axios);
test('should search extensions', async () => {
mock.onAny().replyOnce(200, prettier);
expect(await search('prettier').request).toMatchSnapshot();
});
test('should cancel search', async () => {
mock.onAny().replyOnce(200, prettier);
const { request, cancel } = search('prettier');
cancel();
expect(await request).toBe(undefined);
});
This will test a search and the cancelation of a search. The imported prettier
object is actually the saved response of a real search request against the API with the search query 'prettier'
.
The server package creates language server which offers code completion functionality for VS Code extensions in .vscode/extensions.json
files.
We'll use vscode-languageserver
which is a framework to create a language server. AFAIK it is not tied to VS Code, it was only created by the VS Code team. The description also just says that it is a "Language server implementation for node". Additionally we'll use jsonc-parser
to parse our .vscode/extensions.json
file. (By the way, JSONC is JSON with comments.) We need this to check if the user requests a code completion for an item in recommendations[]
/unwantedRecommendations[]
or if the user requests a code completion for someting completely different.
Structure and config wise (e.g. the src/tsconfig.json
, package.json
, the unit tests) is similar to the core package.
Let's have a look at our src/index.ts
. This should give us a good overview of what happens in this package:
import { connection, documents } from './setup';
import { configureCompletion } from './completion';
configureCompletion(connection, documents);
As you can see we import a connection
and documents
from some setup file and pass them to a function called configureCompletion
. So what are connection
, documents
and configureCompletion
exactly?
connection
and documents
are terms coming from the vscode-languageserver
package. A connection - in my understanding - is the actual server we think of when we say language server. The connection takes requests from a client and responds to them. A connection can take some configurations as you will soon see. For example a connection can say that it offers certain features (called capabilities) like code completion. documents
is an instance of TextDocuments
which is exported by vscode-languageserver
. As far as I understand the TextDocuments
syncs which files have been opened/changed/closed between the client and the server. We use it to retrieve our .vscode/extensions.json
.
Let's have a look at src/setup.ts
first:
import { createConnection, TextDocuments } from 'vscode-languageserver';
const connection = createConnection();
// text document manager (supports full document sync only)
const documents = new TextDocuments();
connection.onInitialize(() => ({
// tells the client what we support:
// - full text sync
// - code completion
capabilities: {
textDocumentSync: documents.syncKind,
completionProvider: {
triggerCharacters: [
...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"'
]
}
}
}));
// Listen for events
documents.listen(connection);
connection.listen();
export { connection, documents };
As I said earlier we create a connection
(our actually server) and an instance of TextDocuments
(to let our server know which documents are opened and changed in the client). We set an onInitialize
handler which returns a configuration which contains capabilities
. These capabilities
tell the client which features our server supports. With textDocumentSync: documents.syncKind
we tell the client that we support full text syncs (e.g. the whole document is synced between client and server) and with completionProvider
we tell the client that our server offers code completion (as you'll soon see). Inside completionProvider
we also specify all characters which trigger a completion. This are basically a
to Z
, -
and _
which can be part of the extension name as well as "
which seems to be treated as a part of a word in JSON (and our string begins with "
). (
After that we pass connection
to our documents
(with documents.listen(connection)
) and tell the connection
to listen for any requests from any client. At the end both are exported, so we can use them inside src/index.ts
as we saw earlier.
Now let's have a look at src/completion.ts
and we do it in two parts. First well have a look at the exported configureCompletion
function which was already used inside src/index.ts
:
import { search, Canceler } from '@donaldpipowitch/vscode-extension-core';
import {
TextDocuments,
CompletionItemKind,
Connection
} from 'vscode-languageserver';
import { parseTree, findNodeAtOffset, Node } from 'jsonc-parser';
let lastCancel: Canceler | null = null;
export function configureCompletion(
connection: Connection,
documents: TextDocuments
) {
// The onCompletion handler provides the initial list of the completion items.
// This is an example of the params passed to the handler:
// {
// "textDocument": {
// "uri": "file:///Users/pipo/workspace/test-txt/test.txt"
// },
// "position": {
// "line": 0,
// "character": 10
// },
// "context": {
// "triggerKind": 1
// }
// }
connection.onCompletion(async ({ position, textDocument }, token) => {
token.onCancellationRequested(() => {
if (lastCancel) {
lastCancel();
lastCancel = null;
}
});
if (lastCancel) {
lastCancel();
lastCancel = null;
}
if (!textDocument.uri.endsWith('.vscode/extensions.json')) return;
const document = documents.get(textDocument.uri);
if (!document) return;
const tree = parseTree(document.getText());
const node = findNodeAtOffset(tree, document.offsetAt(position));
if (!isExtensionValue(node)) return;
// only search for queries with at least three characters
if (node.value && node.value.length <= 2) return;
const query = node.value;
// connection.console.log(`You searched for ${query}.`);
const { cancel, request } = search(query);
lastCancel = cancel;
const extensions = await request;
lastCancel = null;
if (!extensions) return;
const completionItems = extensions.map(
({
publisher: { publisherName },
extensionName,
displayName,
shortDescription
}) => ({
// `"` hack, because JSON treats `"` as part of the completion here
// see https://github.com/Microsoft/vscode-extension-samples/issues/93#issuecomment-429849514
label: `"${publisherName}.${extensionName}`,
kind: CompletionItemKind.Text,
detail: displayName,
documentation: shortDescription
})
);
return completionItems;
});
}
That is a lot to cover! Let's try to break it down a little bit.
configureCompletion
has just one purpose: set a onCompletion
handler on our connection
. A client can basically ask at any time for any file on any position for possible completion items (e.g. suggestion about what could be completed). Because it can happen at any time and because I search can take an unknown time to resolve, we check if there is a pending search (if (lastCancel) {}
) and if there is a pending search, we cancel it. Some clients may even want to cancel an ongoing request for other reasons (e.g. because the user closes the editor) so we provide a token.onCancellationRequested
handler which takes care of canceling the request.
To handle the clients request correctly we get the position
(e.g. the cursor position) and the textDocument.uri
(basically the "file name"). With these two params we can check, if the client requests completion items for a .vscode/extensions.json
file and the values of recommendations[]
or unwantedRecommendations[]
. If it is a different file we return early. If it is a .vscode/extensions.json
file we try to get its content - if it is not available we return as well. If we have the files content, we try to parse it (const tree = parseTree(document.getText())
).
So what is tree
? This is the abstract syntax tree (or AST in short) which represents our JSON file in a way that it can be programmatically searched for example. With our position
we can get the node
inside the tree
which the cursor position of the user currently highlights. The node
is like an element inside the tree which can represent different things: a string value or an array or the property of an object or a comment... basically every language feature JSON(C) can support.
We pass the node
to a function called isExtensionValue()
. We'll look at this function in a moment. For now you should only know that this function returns true
, if node
represents a string value inside recommendations[]
or unwantedRecommendations[]
. With other words: given a JSON file like { "recommendations": ["some-value"] }
than isExtensionValue()
returns true
, if the cursor is somewhere inside "some-value"
and it returns false
in all other cases.
If our node
is the value of an extension we check if it is at least two characters long, before we try to run a search.
When we run the search, we save the cancel
handler. Remember our initial check - if the search takes a long time and the client already asks for the next completion items we'll cancel the old search. When the search has finished we reset the cancel
handler. Another thing to remember: if we canceled a search request than the request will be resolved to undefined
and we just stop any further processing.
Great! As our last step we just need to map our extension
's from the API to a completion item. The kind
is CompletionItemKind.Text
which means our completion item just represents some text (instead of something like a function a file or something like that). label
is the actual value we want to complete to. It is basically ${publisherName}.${extensionName}
, but with a caveat - we need to prepend a "
. You can find some discussion about this caveat here. Apparently the "
is treated as a part of the completable value. I'm not sure if this is a bug or not, but we need to respect it. detail
acts like a title in the extended view of the completion UI while documentation
acts like a small description below the title.
Note that the completion can be either triggered just by writing or by pressing Ctrl
and Space
(at least with your default settings).
Congratualions. Take a deep breath, make a small pause and maybe re-read the last section.
Now let's have a look at the implementation of isExtensionValue()
:
function isExtensionValue(node: Node | undefined): node is Node {
// no node
if (!node) return false;
// not a string node
if (node.type !== 'string') return false;
// not within an array
if (!node.parent) return false;
if (node.parent.type !== 'array') return false;
// not on a "recommendations" or "unwantedRecommendations" property
if (!node.parent.parent) return false;
if (node.parent.parent.type !== 'property') return false;
if (
node.parent.parent.children![0].value !== 'recommendations' &&
node.parent.parent.children![0].value !== 'unwantedRecommendations'
)
return false;
// not on an object
if (!node.parent.parent.parent) return false;
if (node.parent.parent.parent.type !== 'object') return false;
// not the root
if (node.parent.parent.parent.parent) return false;
// whoohoo
return true;
}
This looks a little bit complicated, but should actually be quite straightforward. We just make sure our node
exists, is a string inside an array of a prop which is called recommendations
or unwantedRecommendations
of an object in the root. If yes, we return true
.
The only thing left for the server are a couple of small unit tests for the code completion which you can find here. The completion logic depends on three things: our connection
, our documents
and our search
from the core
. We'll need to mock these three things in a setup
function which we can call inside every test. For the search
we'll use a fixture of a saved search for "prettier" which you can find here. documents
will just return some text
and offset
which we can pass into setup
- these are the only things which we'll change between tests for now. The interesting part is in the mock for connection
. Here we use the onCompletion
mock to retrieve our actual completionHandler
, so we can call it manually inside a test. This is our final setup
:
import * as core from '@donaldpipowitch/vscode-extension-core';
import { configureCompletion } from '../src/completion';
import { prettier } from './__fixtures__/search';
const setup = ({ text, offset }: { text: string; offset: number }) => {
const search = jest.spyOn(core, 'search');
let resolve: Function;
const request = new Promise((_resolve) => (resolve = _resolve));
const cancel = jest.fn(() => resolve());
search.mockReturnValue({ cancel, request });
const mockedConnection = { onCompletion: jest.fn() };
const mockedDocuments = {
get() {
return {
getText() {
return text;
},
offsetAt() {
return offset;
}
};
}
};
configureCompletion(mockedConnection as any, mockedDocuments as any);
expect(mockedConnection.onCompletion).toHaveBeenCalledTimes(1);
const completionHandler = mockedConnection.onCompletion.mock.calls[0][0];
return {
callCompletionHandler: () =>
completionHandler(
{
textDocument: { uri: '.vscode/extensions.json' }
},
{ onCancellationRequested: () => {} }
),
resolveSearch: () => resolve(prettier),
cancelSearch: cancel
};
};
This is surely not the best code ever written, but it makes our tests quite readable.
In this test for example we check for the succesful completion of a prettier
search. We prepare our JSON file (text
) and the cursor position (offset
). We ask for completion items (callCompletionHandler
), "wait" for the search (resolveSearch
) and await
the completion items (itemsPromise
):
test('should provide completion items (search "prettier")', async () => {
const text = '{"recommendations": ["prettier"]}';
const offset = 21; // the `"`, before `prettier"]`
const mocks = setup({ text, offset });
const itemsPromise = mocks.callCompletionHandler();
mocks.resolveSearch();
expect(await itemsPromise).toMatchSnapshot();
});
For more examples you can have a look at the test file (- they all look quite similar).
The client package creates our VS Code extension and uses the language server. This package contains IDE-specific logic - in this case for VS Code.
Let's begin with the package.json
this time, which contains some VS Code specific properties which I'll briefly explain. You can find all properties with descriptions and their allowed values here.
{
// ...
"publisher": "donaldpipowitch",
"engines": {
"vscode": "^1.25.0"
},
"activationEvents": ["workspaceContains:**/.vscode/extensions.json"],
"scripts": {
"postinstall": "vscode-install",
"vscode:prepublish": "..."
// ...
},
"dependencies": {
"@donaldpipowitch/vscode-extension-server": "^1.0.0",
"vscode-languageclient": "^5.1.1"
},
"devDependencies": {
// ...
"vscode": "^1.1.21"
}
// ...
}
publisher
is a required field which represents the person or organisation which publishes this extension. I'll show you later how you create a publisher and I'll also explain scripts['vscode:prepublish']
in the publishing section. engines.vscode
is also required and specifies which VS Code versions your extension supports.
activationEvents
is not required. You use activation events to tell VS Code when your extension needs to be loaded, so your extension isn't loaded automatically in every project and slows you down, even if you don't need it. In this case I said that the extension should be loaded, if a .vscode/extensions.json
file can be found in the workspace.
scripts.postinstall
calls a script called vscode-install
provided by the vscode
package, which is a part of our devDependencies
. We don't use this package directly in our client, but it is needed by our other dependency vscode-languageclient
. vscode
depends on our engines.vscode
setting and generates some files like type declarations when we call vscode-install
.
Now we can dive into our src/index.ts
:
import {
LanguageClient,
LanguageClientOptions,
ServerOptions
} from 'vscode-languageclient';
let client: LanguageClient;
export function activate() {
const serverModule = require.resolve(
'@donaldpipowitch/vscode-extension-server'
);
// Debug options for server are used when we launch the extension in debug mode
const serverOptions: ServerOptions = {
run: { module: serverModule },
debug: {
module: serverModule,
options: { execArgv: ['--nolazy', '--inspect=6009'] }
}
};
const clientOptions: LanguageClientOptions = {
documentSelector: [
{
pattern: '**/.vscode/extensions.json',
scheme: 'file'
}
]
};
client = new LanguageClient(
'vscode-extensions-files',
'VS Code Extension Client',
serverOptions,
clientOptions
);
// Starting the client will also launch the server.
client.start();
}
export function deactivate() {
if (client) {
return client.stop();
}
}
From a high-level perspective our extension exports an activate
and a deactivate
function which will be called by VS Code. Both share a client
variable which is an instance of LanguageClient
. The client
will be created and started by activate
and will be stopped by deactivate
.
The interesting part is the client
itself and how it is configured inside activate
. A LanguageClient
instance takes an id
(in this case 'vscode-extensions-files'
) and a name
(in this case 'VS Code Extension Client'
) for logging purposes and two kinds of configurations: serverOptions
and clientOptions
.
The serverOptions
itself comes in two flavors as well. We have the default run
options and debug
options. In both cases we specify our (language server) module
in the form of a resolved path to '@donaldpipowitch/vscode-extension-server'
. In the debug
case we also set the port which can be used for the debugging inspection.
The clientOptions
are a little bit more interesting. With the documentSelector
we say when our client should use the language server. In this case everytime a file matches the path '**/.vscode/extensions.json'
.
Let's recap:
- The
activationEvents
from ourpackage.json
tell VS Code when to load our extension. - The
clientOptions.documentSelector
tell our client when to use the server (e.g. for code completion). - The language server then checks (e.g. on code completion), if the file is actually a
.vscode/extensions.json
. (This looks a little bit redundant, but the server has no control over the client and the client could ask for code completion in other files.)
Let's skip the unit test this time, because our package only contains a little bit configuration. (Sorry!)
We have everything in place now to actually test our extension! π
I created a .vscode/launch.json
which allows you to launch our extension in a new windows and which adds a debugger to our language server. All you have to do, is to switch into the debugging panel, choose "Client + Server" and click on the green arrow to start debugging. This will automatically open the example/
directory which you can use to debug the extension. (If you want to learn more about .vscode/launch.json
files you can have a look at this documentation.)
If you see a new window with "Extension Development Host" in the title bar and "VS Code Extension Client" in the output tab with a "Debugger attached." message, everything should be fine! β€οΈ You should now be able to create a .vscode/extensions.json
file and try to get some code completion. In this case I've written "prettier"
and I see several code completions for this search term from the Marketplace API.
Awesome. We're ready to ship our extension now.
Before we'll publish the packages I check different things like:
- every package should have
README.md
- every package should have
CHANGELOG.md
- every
package.json
should have alicense
field and the so on
We'll also add a .vscodeignore
to our client
package and an .npmignore
file to the other packages, so we only publish files we need at runtime. (Instead of an .npmignore
file we can also use the files
field inside a package.json
for npm, but it looks like VS Code doesn't support this. To be consistent we'll just use the ignore files in both cases.) Note that some directories like node_modules/
are never included and some files like the package.json
are always included. For npm you can check what will be published by calling $ npm publish --dry-run
. VS Code offers the same functionality, but I describe it in a minute, because we need to install another tool to before we can do that.
I have previously written in-depth about publishing packages to npm in a different tutorial. If you never published a package before, you'll find all the information you need there. In general you have to register add npmjs.com, login with your credentials by running $ npm login
in your terminal and than run $ npm publish --access public
inside the core
and server
packages. This is what I did for the initial 1.0.0
version. The --access public
is needed, because we used a scoped package (- it is scoped, because I used @donaldpipowitch
in the package name).
To publish an extension to the "Visual Studio Code Marketplace" you'll need an Azure DevOps account. (You can also use an existing Microsoft account.) Once your able to visit Azure DevOps, click on your avatar in the upper right corner and click on "security" in the menu (or visit https://dev.azure.com/{your-name}/_usersSettings/tokens
and change {your-name}
to... your account name). You should create a personal access token now. Make sure to give it a name (like vscode
or vscode-extensions
or something like that), some expiration date and select the scopes "Acquire" and "Manage" for "Marketplace":
Copy the token to a safe place! We need it multiple times. Next we'll create a publisher. In VS Codes own words a "publisher is an identity who can publish extensions to the Visual Studio Code Marketplace". Remember that we already specified a publisher in our client/package.json
. Make sure that the publisher you'll now create is the same as you specified in your client/package.json
or you'll get really weird error messages (Access Denied: {my-name} needs the following permission(s) on the resource /{my-publisher} to perform this action: View user permissions on a resource
). To create a publisher we'll need the Visual Studio Code Extension Manager or short vsce
. I used donaldpipowitch
as my publisher name:
$ npm install -g vsce
$ vsce create-publisher donaldpipowitch
Publisher human-friendly name: (donaldpipowitch) Donald Pipowitch
E-mail: [email protected]
Personal Access Token: ****************************************************
Successfully created publisher 'donaldpipowitch'.
You can also visit the Marketplace administration to create and manage publishers.
Now we're ready to publish our extension. While our npm-published extensions used a prepublish
hook in the scripts
section of our package.json
to build our package before publishing, we'll now need a vscode:prepublish
hook. But it will not just build our package, it'll also install our dependencies with npm. That sounds confusing! In contrast to an npm-published package we'll publish our VS Code extension with dependencies. But because I used yarn to manage this project our runtime dependencies are hoisted to the root of the project. By using npm we can install your dependencies in the extension folder just before publishing.
{
// ...
"scripts": {
"vscode:prepublish": "npm install --no-package-lock --production && yarn build"
// ...
}
}
Two additional notes:
vsce
filters out dev dependencies, before publishing the extension. So we don't need the--production
flag, but it makes the installation a little bit faster.- There is no
vscode:postpublish
hook. I would love to use"vscode:postpublish": "yarn"
to get in a clean state again.
Why did I used yarn in the first place? I basically use the yarn workspace feature in all of my projects. I found it to be really useful to avoid strange bugs appearing, because a package was used multiple times, to developed multiple dependent packages localy and to share the exact same build tools across multiple packages.
I previously said there would be an equivalent to $ npm publish --dry-run
, which is called $ vsce ls
. This will list all the files which will be published with your extension. Make sure all the files you need are included.
Please note: If you follow this tutorial very closely please don't run the next command so the Visual Studio MarketPlace isn't flooded by duplicated extensions. Thank you! βοΈ
Now we can publish our package:
$ vsce publish
Wow! π Take a small break. You really accomplished something great.
Within seconds you should be able to see your extension in the marketplace. Here is mine.
There are two additional chapters left I want to write:
- I want to show a small description of the extension on hover
- I want to add a go to definitions feature which brings you to the marketplace page of that extension on click.
Feel free to watch this repository. π
Thank you for reading this article. β₯
I highly appreciate pull requests for grammar and spelling fixes as I'm not a native speaker as well as pull requests to make the code simpler and more idiomatic. Thank you!