A First Server¶
Implementation Skeleton¶
OK, enough of the talking - let’s code. I’m assuming a Unix-like environment below, with a bash
shell. That should work for Linux, MacOS and Windows subsystem for Linux (WSL).
Pre-Requisites¶
We’re using vscode as the editor, so you need to install it first. You’ll also need to install git, Node.js and Python.
Then create a new directory to hold the project:
1cd /my/projects/dir
2mkdir helloLSP
3cd helloLSP
Note
You can copy all the code from the git repository here if you’d prefer not to follow these steps.
There’s a fair bit of boilerplate that needs to be in place before we can really get started on implementing support for greet
. It’s a bit fiddly and difficult to get right from first principles. However, luckily, we don’t need to. I used this example as a template. There are many examples available and reading some others too is worthwhile to get a sense of what’s involved.
To build initially and check it’s working:
Set up the Server Python environment
1python3 -m venv venv 2source venv/bin/activate 3python3 -m pip install -U pip 4python3 -m pip install pygls
Set up the client nodejs environment
1npm install 2cd client 3npm install 4cd ..
Start vscode in the project root dir
1code .
Edit
vscode/settings.json
to ensure the python interpreter path is set correctly:1{ 2 "python.interpreterPath": "${workspaceFolder}/venv/bin/python3" 3}
You’ll need to adjust this as required for your platform. On Windows, this is likely to be
${workspaceFolder}/venv/Scripts/python
.Run the extension client and server in a separate “development” instance of vscode by typing
ctrl-shift-D
, selectingServer + Client
in the “Launch” dropdown at the top of the screen, and hittingF5
.In the development instance of vscode, open the
samples
sub-directory of this project.Open the sample json file. The editor should show an information message at the bottom of the main window that says “Text Document Did Open”.
With that done, the basics are all in place. Close the development instance for now and go back to the main project instance. The code at this point is tagged as v0.1 if you want to have a look.
Anatomy of the Plugin¶
Despite all the boilerplate, there are 3 primary files that implement the plugin:
client/src/extension.ts implements the client
server/server.py implements the server.
package.json describes the capabilities that the client and server provide.
Tidying up the skeleton¶
Note
If you’re eager to get onto actually implementing language support, then skip ahead. This section cleans up the skeleton and gets ready for that. Understanding how things fit together can be instructive, but if it’s not your bag, move along.
With the skeleton in place, we can start making the changes needed to support our greet
language. There are a few housekeeping tasks to complete:
Change the plugin so it’s activated on files with a
.greet
extension (the skeleton is activated forjson
files)Get rid of the extraneous commands supported by the skeleton that we don’t need.
Rename the relevant classes, methods and names from
json
togreet
Tiny baby steps - setting the language¶
Let’s start with the filename extension. There’s actually 2 parts to this, because vscode separates language identity from the filename extension. That allows a single language to support multiple extensions. For example: the Java tooling supports both .jav
and .java
extensions.
The language and extension(s) are configured in the package.json
file. The relevant section in the skeleton reads as follows:
1"activationEvents": [
2 "onLanguage:json"
3 ],
We need to make a few changes. For a start, the skeleton assumes vscode already knows about json
as a language. It won’t know anything about greet
though. So we need to define the language identity, define the file extensions, and let vscode know when to activate our plugin. Here’s what that looks like[4]
1"contributes": {
2 "languages": [
3 {
4 "id": "greet",
5 "aliases": [
6 "Greet",
7 "greet"
8 ],
9 "extensions": [
10 ".greet"
11 ]
12 }
13 ]
14},
15"activationEvents": [
16 "onLanguage:greet"
17 ],
It’s also referenced in client/src/extension.ts
:
1function getClientOptions(): LanguageClientOptions {
2 return {
3 // Register the server for plain text documents
4 documentSelector: [
5 { scheme: "file", language: "json" },
6 { scheme: "untitled", language: "json" },
7 ],
8 //...
9 }
10}
We need to change that to:
1function getClientOptions(): LanguageClientOptions {
2 return {
3 // Register the server for 'greet' documents
4 documentSelector: [
5 { scheme: "file", language: "greet" },
6 { scheme: "untitled", language: "greet" },
7 ],
8 //...
9 }
10}
With those changed, we can launch the plugin in a development window again (ctrl-shift-D
, select “Server + Client”, hit F5
). Open samples/valid.greet
in the editor and, again, you should see the Text Document Did Open
message. Close the development instance. Change 1 complete.
Cleaning up¶
The skeleton plugin implements multiple commands for illustration. We don’t need them here, so they can be removed. If you run the plugin in a development instance and type ctrl-shift-p
then enter “countdown” in the dialogue box, you should see several options like “Count down 10 seconds [Blocking]”. That needs changes in 2 places:
package.json
, which declares the commands the plugin supportsserver.py
which implements them.
Here’s an excerpt from each.
1//package.json
2 "commands": [
3 {
4 "command": "countDownBlocking",
5 "title": "Count down 10 seconds [Blocking]"
6 },
7 // several more
8 ]
1# server.py
2class JsonLanguageServer(LanguageServer):
3 CMD_COUNT_DOWN_BLOCKING = 'countDownBlocking'
4 # several more
5
6@json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING)
7def count_down_10_seconds_blocking(ls, *args):
8 """Starts counting down and showing message synchronously.
9 It will `block` the main thread, which can be tested by trying to show
10 completion items.
11 """
12 for i in range(COUNT_DOWN_START_IN_SECONDS):
13 ls.show_message(f'Counting down... {COUNT_DOWN_START_IN_SECONDS - i}')
14 time.sleep(COUNT_DOWN_SLEEP_IN_SECONDS)
The link between the two is the countdownBlocking
literal. Removing the unnecessary commands requires removing:
the “commands” entry in
package.json
The corresponding constant definition in the
JsonLanguageServer
classThe python function that implements the class
We’ll remove the configuration and code for the following commands:
countDownBlocking
CountDownNonBlocking
showConfigurationAsync
showConfigurationCallback
showConfigurationThread
Launching the development instance, typing ctrl-shift-p
and entering “countdown” now doesn’t show up our commands. Task complete.
Naming: enough, already, Json¶
The skeleton is based on support for json
files, and that’s used throughout client/src/extension.ts
, server/server.py
and package.json
.
Package.json¶
Let’s start with package.json
. The first chunk of relevance sits right at the top of the file:
1{
2 "name": "json-extension",
3 "description": "Simple json extension example",
4 "author": "Open Law Library",
5 "repository": "https://github.com/openlawlibrary/pygls",
6 "license": "Apache-2.0",
7 "version": "0.11.3",
8 "publisher": "openlawlibrary",
9 //...
10}
We can change that as follows:
1{
2 "name": "greet",
3 "description": "Support for the greet salutation example language",
4 "author": "sfinnie",
5 "repository": "https://github.com/sfinnie/helloLSP",
6 "license": "Apache-2.0",
7 "version": "0.0.1",
8 "publisher": "sfinnie",
9 //...
10}
The next bit is the configuration section. It currently reads:
1 "configuration": {
2 "type": "object",
3 "title": "Json Server Configuration",
4 "properties": {
5 "jsonServer.exampleConfiguration": {
6 "scope": "resource",
7 "type": "string",
8 "default": "You can override this message."
9 }
10 }
11 }
It’s a bit academic, because we don’t have any configuration at the moment. It’s helpful to see how it fits together though, so let’s leave it in but update it:
1 "configuration": {
2 "type": "object",
3 "title": "Greet Server Configuration",
4 "properties": {
5 "greetServer.exampleConfiguration": {
6 "scope": "resource",
7 "type": "string",
8 "default": "Greet says you can override this message."
9 }
10 }
11 }
That’s package.json
done. Let’s do the client next.
extension.ts¶
The only relevant section in the client is in the getClientOptions()
function that we updated earlier. Here’s how it looks currently:
1function getClientOptions(): LanguageClientOptions {
2 return {
3 // Register the server for 'greet' documents
4 documentSelector: [
5 { scheme: "file", language: "greet" },
6 { scheme: "untitled", language: "greet" },
7 ],
8 outputChannelName: "[pygls] JsonLanguageServer",
It’s the last line we want to change, as follows:
8 outputChannelName: "[pygls] GreetLanguageServer",
I’ve left [pygls]
in there. It’s completely optional, but it might be useful to know that’s what the server is based on. If nothing else, it’s a nice little acknowledgement for the good folks who put pygls
together and shared it with the world.
That’s it for the client - onto the server.
server.py¶
The change in the server is the main language server class:
1class JsonLanguageServer(LanguageServer):
2 CMD_PROGRESS = 'progress'
3 #...
4
5json_server = JsonLanguageServer('pygls-json-example', 'v0.1')
Unsurprisingly, that becomes:
1class GreetLanguageServer(LanguageServer):
2 CMD_PROGRESS = 'progress'
3 #...
4
5greet_server = GreetLanguageServer('pygls-greet-example', 'v0.1')
greet_server
is referenced in several places in the file, so they all need updated. Same with GreetLanguageServer
.
There’s a couple of functions for validating the contents of a file (_validate()
, _validate_json()
). We’re going to update them later, so can leave the json
references for now.
That’s the renaming complete. Time, at last, to actually start implementing our support for greet.
Implementing Greet Language Support¶
Hurrah! Finally time to implement the language support. But what does that actually mean? The Language Server Protocol defines several language features, for example:
We’ll start with checking that a greet
file is consistent with the greet grammar. We know from earlier that notifications can be sent from the client to the server and vice-versa. One of those notifications is textDocument/didOpen
which is sent when a document, of the type supported by the plugin, is opened. We saw that in the development instance: it displayed the message Text Document Did Open
when we opened a file with the .greet
extension. Here’s the source of that in server.py
:
1@greet_server.feature(TEXT_DOCUMENT_DID_OPEN)
2async def did_open(ls, params: DidOpenTextDocumentParams):
3 """Text document did open notification."""
4 ls.show_message('Text Document Did Open')
5 _validate(ls, params)
The @greet_server.feature
line is where pygls
does its magic. Behind that simple line, pygls
manages receiving the notification in json-rpc
format, parsing out the parameters into a DidOpenTextDocumentParams
object, and calling the did_open
function. Aside from showing the message we saw on screen earlier[5], the function calls _validate()
to check the file contents [6].
The skeleton _validate()
function checks whether the file is valid json
so we need to change that. The compiler world tends to talk about parsing rather than validating. It’s a bit pedantic, but I’m going to stick to that here. So the functions we’ll look at are named _parse()
not _validate()
. The skeleton functions cover the following:
Extracting the source file contents from the parameters
Checking the contents
It’s worth noting at this point that the DidOpenTextDocumentParams
object contains the actual content of the file being edited, not just a uri
reference for it. This is a deliberate design decision in the protocol. From the spec:
The document’s content is now managed by the client and the server must not try to read the document’s content using the document’s Uri.
Back to the functions. The first one doesn’t change (other than the name and log message):
1def _parse(ls: GreetLanguageServer, params: DidOpenTextDocumentParams):
2 ls.show_message_log('Validating greeting...')
3
4 text_doc = ls.workspace.get_document(params.text_document.uri)
5
6 source = text_doc.source
7 diagnostics = _parse_greet(source) if source else []
8
9 ls.publish_diagnostics(text_doc.uri, diagnostics)
Note the error handling if there’s no source content. ls.publish_diagnostics()
passes any errors found back to the client for display in the editor. The meat is in the _parse_greet()
function. How do we parse the file? The grammar tells us the rules for a greeting, but how do we implement it? The skeleton takes advantage of Python’s json.loads()
function to do the parsing. Here’s the skeleton implementation:
1def _parse_greet(source):
2 """Validates json file."""
3 diagnostics = []
4
5 try:
6 json.loads(source)
7 except JSONDecodeError as err:
8 msg = err.msg
9 col = err.colno
10 line = err.lineno
11
12 d = Diagnostic(
13 range=Range(
14 start=Position(line=line - 1, character=col - 1),
15 end=Position(line=line - 1, character=col)
16 ),
17 message=msg,
18 source=type(greet_server).__name__
19 )
20
21 diagnostics.append(d)
22
23 return diagnostics
The majority of the code deals with creating the Diagnostic
- the data structure that informs the editor where the error lies, and what the problem is.
Not unreasonably, Python’s standard library doesn’t have a built-in function for reading .greet
files. There’s lots of well-established theory on parsing, several techniques, and lots of libraries to support it. We’ll explore those later. But for now, that’s a bit overkill for our needs here. Our approach is broadly:
Read in the file, breaking it up into lines
For each line, check if it contains a valid greeting:
Does it start with either “Hello” or “Goodbye”?
If so, is it followed by a name that satisfies the
[a-zA-Z]+
pattern?
Here’s the implementation:
1def _parse_greet(source: str):
2 """Parses a greeting file.
3 Generates diagnostic messages for any problems found
4 """
5 diagnostics = []
6
7 grammar = re.compile(r'^(Hello|Goodbye)\s+([a-zA-Z]+)\s*$')
8
9 lines = [line.rstrip() for line in source.splitlines()]
10 for line_num, line_contents in enumerate(lines):
11 if len(line_contents) == 0:
12 # Don't treat blank lines as an error
13 continue
14
15 match = re.match(grammar, line_contents)
16 if match is None:
17 d = Diagnostic(
18 range=Range(
19 start=Position(line=line_num, character=0),
20 end=Position(line=line_num, character=len(line_contents))
21 ),
22 message="Greeting must be either 'Hello <name>' or 'Goodbye <name>'",
23 source=type(greet_server).__name__
24 )
25 diagnostics.append(d)
26
27
28 return diagnostics
The first thing to note is the grammar:
7grammar = re.compile(r'^(Hello|Goodbye)\s+([a-zA-Z]+)\s*$')
It’s a regular expression (aka regex) and shows off the pros and cons of using them. On the ‘pro’ side, it’s a very succinct way of expressing the grammar. On the ‘con’ side, it’s a very succinct way of expressing the grammar. This is about the limit of regex complexity I’m personally comfortable with: it’s still readable with some concentration. Much more than this, though, and I’d want to take a different approach. For sake of clarity, let’s break it down.
The
^
means the grammar must match the start of the line: there can’t be any characters (including white space) before the first patternThe first pattern matches the salutation:
(Hello|Goodbye)
means match the string ‘Hello’ or the string ‘Goodbye’. The brackets mean the match will capture which string matched: we’re not using that here but will later.The second pattern -
\s+
- means match one or more white space characters after the salutation and before what follows.\s
means match a single whitespace character (space, tab);+
means match one or more.The third pattern matches the name:
([a-zA-Z]+)
. This is exactly the same as the formal grammar defined previously. Again, the surrounding brackets mean the value should be captured if match is successfulThe final pattern -
\s*$
- means match zero or more whitespace characters before the end of the line.\s
means match a single whitespace character as before;*
means match zero or more; and$
means match the end of the string.
We iterate through each line in the file in turn, checking if it matches the grammar. If it doesn’t, we create a Diagnostic
instance that specifies the location of the problem and the error message to show. In this first incarnation, we’re not breaking down where the error lies. That’s viable with a grammar as simple as greet
in its current form. Anything more complex and we’d want to be a bit more specific with error messages, but it’s plenty good for now. Spin up a development instance (ctrl-shift-D
and f5
), open the sample .greet file, and have a play. The editor should show a red squiggly for any lines that don’t match the grammar, and show the diagnostic message if you hover over an error:
Play about in the development instance, and the editor will respond as a line moves between being valid and invalid. Now, if you’ve had your porridge/coffee/whatever, and are feeling super alert, you might be wondering why. So far, we’ve only looked at the textDocument/didOpen
message. That only gets sent when a file is opened. So how is the editor responding as we edit the file? The answer is the skeleton already implements another notification, textDocument/didChange
:
1@greet_server.feature(TEXT_DOCUMENT_DID_CHANGE)
2def did_change(ls, params: DidChangeTextDocumentParams):
3 """Text document did change notification."""
4 _parse(ls, params)
Compare it to the textDocument/didOpen
function above and you’ll see the implementation is exactly the same: call the _parse()
function. So parsing gets invoked both when a file is opened, and when it’s changed.
That’s it for our first language feature implementation - job done. It’s notable that the code for actually checking the file has taken a lot less column inches than all of the preparation that preceded it. Of course, greet
is a trivial language. And, so far, we’ve only implemented basic diagnostics.
The code at this point is tagged as v0.2.
Before we add any additional capabilities, we should think about testing.