There are a lot of IDEs out there that offer features like auto complete, go to definition, etc. Traditionally each IDE has logic for parsing and understanding code in a project, and plugs this understanding into its UI. How good the IDE’s understanding of a particular language together with their user experience are their most powerful selling points.
In the past, different IDEs that supported the same language had their own proprietary code that gave them insights into a programming language.
LSP is an open source standard created by Microsoft. It defines a protocol for a program that understands a programming language to talk to another program (typically an IDE) that wants to take advantage of these features. This protocol allows anybody to build a server that understands a language and make it available to the world. Programmers can leverage these servers to provide powerful tools or IDEs.
The server
The server takes care of understanding the structure of a program and implementing an API to share this understanding.
There are various server implementations out there and they vary widely in the number of features they support.
The client
The client is usually an IDE. It provides users the ability to open and edit source code files. With help from the server it can provide things like code highlighting or a UI for autocompletion, among other things.
Client-Server communication
The relationship between the client and the server can feel a little unintuitive at first, but it’s well suited for the IDE use case.
Typically, users start their interaction with this ecosystem by opening their IDE. At this point, there are a few things that happen:
- The IDE starts a server at some port (This is the client)
- The IDE starts the Language Server and tells it to find the client at
localhost:<some port>
- The Language Server connects to the client
- The client sends an
initialize
request to the server - The server responds to this request advertising its capabilities (The features it supports)
- Client and Server communicate using LSP
- Client sends
shutdown
message - Server reponds to
shutdown
- Client sends
exit
message - Server shuts down
At a high level that’s how the system works, but to make it easier to visualize, we can use one of the available servers to see this in action.
LSP in action
Now that we know at a high lever how LSP works, we can get a better understanding of it by using LSP to talk to a well known server implementation.
At the time of this writing, Eclipse JDT Language Server is the most popular LSP implementation for Java. We can get their binaries from Eclipse’s downloads page. I’m going to be using jdt-language-server-1.6.0-202111250302.tar.gz, but you can probably use the most recent version.
Once we have the tarball, we’ll need to extract it:
1
2
mkdir jdt-server
tar -zxf jdt-language-server-1.6.0-202111250302.tar.gz -C jdt-server
In the previous section we mentioned that the first step is starting the client, so let’s start by building a very simple Java program that will act as client:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package example;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class LspClient {
BufferedReader in;
PrintWriter out;
ServerSocket serverSocket;
Socket clientSocket;
LspClient() throws Exception {
serverSocket = new ServerSocket(6666);
System.out.println("Waiting for server to connect");
clientSocket = serverSocket.accept();
System.out.println("Server connected");
in = new BufferedReader(
new InputStreamReader(clientSocket.getInputStream())
);
out = new PrintWriter(clientSocket.getOutputStream(), true);
}
/**
* Processes incoming messages from the server
*/
private void processMessages() throws Exception {
// All messages start with a header
String header;
while ((header = in.readLine()) != null) {
// Read all the headers and extract the message content from them
int contentLength = -1;
while (!header.equals("")) {
System.out.println("Header: " + header);
if (isContentLengthHeader(header)) {
contentLength = getContentLength(header);
}
header = in.readLine();
}
System.out.println("Reading body");
// Read the body
if (contentLength == -1) {
throw new RuntimeException("Unexpected content length in message");
}
char[] messageChars = new char[contentLength];
in.read(messageChars, 0, contentLength);
System.out.println(messageChars);
}
}
private boolean isContentLengthHeader(String header) {
return header.toLowerCase().contains("content-length");
}
private int getContentLength(String header) {
return Integer.parseInt(header.split(" ")[1]);
}
private void close() throws Exception {
in.close();
out.close();
clientSocket.close();
serverSocket.close();
}
public static void main(String[] args) throws Exception {
LspClient lspClient = new LspClient();
lspClient.processMessages();
lspClient.close();
}
}
Our client currently doesn’t do anything other than opening a server in port 6666
and printing all the messages it receives.
The second step is starting our server. The main file we care about is plugins/org.eclipse.equinox.launcher_...
. We can go to the jdt-server
folder we created above and run this command:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java \
-Declipse.application=org.eclipse.jdt.ls.core.id1 \
-Dosgi.bundles.defaultStartLevel=4 \
-Declipse.product=org.eclipse.jdt.ls.core.product \
-Dlog.level=ALL \
-DCLIENT_PORT=6666 \
-noverify \
-Xmx1G \
-jar plugins/org.eclipse.equinox.launcher_1.6.400.v20210924-0641.jar \
-configuration ./config_linux \
-data ./data \
--add-modules=ALL-SYSTEM \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED
There are a few important things to mention about this command:
-DCLIENT_PORT=6666
- Tells the server that it can find the client atlocalhost:6666
-configuration ./config_linux
- I’m choosing the Linux config because I’m running on Linux. There are different folders available for different OS-data ./data
- The server needs a folder where it will store information about the current session. Any empty folder will do
When I run this command and look at the client I see this output:
1
2
3
4
5
Waiting for server to connect
Server connected
Header: Content-Length: 126
Reading body
{"jsonrpc":"2.0","method":"window/logMessage","params":{"type":3,"message":"Nov 26, 2021, 3:15:32 PM Main thread is waiting"}}
Our code expects messages to follow the LSP format, which consists of a header
and a content
. Headers are very similar to HTTP headers and come in the format:
1
Header-Name: value
Each header must be followed by \r\n
. After all headers there will be an additional \r\n
. This means that after the last header there will be two sets of \r\n
, followed by the content
.
The content
varies depending on the message, but always uses the JSON_RPC format and this general shape:
1
2
3
4
5
6
{
"jsonrpc": "2.0",
"id": <some id for this message>,
"method": "<some method name>",
"params": <params object depending on the message>
}
A full mesage looks something like this:
1
2
3
4
5
6
7
8
Content-Length: 127\r\n
\r\n
{
"jsonrpc": "2.0",
"id": <some id for this message>,
"method": "<some method name>",
"params": <params object depending on the message>
}
Knowing this, we can see that the server sent a message of type window/logMessage
, which simply tells us that it’s ready.
Following the protocol, the next step is for the client to send an initialize
message. We can add that step to our little client:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package example;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class LspClient {
BufferedReader in;
PrintWriter out;
ServerSocket serverSocket;
Socket clientSocket;
LspClient() throws Exception {
serverSocket = new ServerSocket(6666);
System.out.println("Waiting for server to connect");
clientSocket = serverSocket.accept();
System.out.println("Server connected");
in = new BufferedReader(
new InputStreamReader(clientSocket.getInputStream())
);
out = new PrintWriter(clientSocket.getOutputStream(), true);
}
/**
* Processes incoming messages from the server
*/
private void processMessages() throws Exception {
// All messages start with a header
String header;
while ((header = in.readLine()) != null) {
// Read all the headers and extract the message content from them
int contentLength = -1;
while (!header.equals("")) {
System.out.println("Header: " + header);
if (isContentLengthHeader(header)) {
contentLength = getContentLength(header);
}
header = in.readLine();
}
System.out.println("Reading body");
// Read the body
if (contentLength == -1) {
throw new RuntimeException("Unexpected content length in message");
}
char[] messageChars = new char[contentLength];
in.read(messageChars, 0, contentLength);
handleMessage(String.valueOf(messageChars));
}
}
private void handleMessage(String message) {
System.out.println("Message: " + message);
if (message.contains("PM Main thread is waiting")) {
System.out.println("Server is ready. Initializing");
sendIntialize();
}
}
private void sendIntialize() {
System.out.println("Sending initialize message");
String initializeMessage = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"clientInfo\":{\"name\":\"MyTestClient\",\"version\":\"1\"}}}";
sendMessage(initializeMessage);
}
private void sendMessage(String body) {
String header = "Content-Length: " + body.getBytes().length + "\r\n";
final String message = header + "\r\n" + body;
out.println(message);
}
private boolean isContentLengthHeader(String header) {
return header.toLowerCase().contains("content-length");
}
private int getContentLength(String header) {
return Integer.parseInt(header.split(" ")[1]);
}
private void close() throws Exception {
in.close();
out.close();
clientSocket.close();
serverSocket.close();
}
public static void main(String[] args) throws Exception {
LspClient lspClient = new LspClient();
lspClient.processMessages();
lspClient.close();
}
}
If we run this client code and start the server, we’ll get back a lot of messages, but the most important one is the response to our message:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"capabilities": {
"callHierarchyProvider": true,
"codeActionProvider": true,
"codeLensProvider": {
"resolveProvider": true
},
"completionProvider": {
"resolveProvider": true,
"triggerCharacters": [
".",
"@",
"#",
"*"
]
},
...
}
}
}
The first thing to notice is the id
field. This field is used to match responses to requests when multiple requests are sent to the server.
As part of the response, the server also advertises its capabilities, so the client knows which features it can provide.
Conclusion
The goal of this article is to provide a short practical guide for LSP. The Language Server Protocol Specification explains the whole protocol in detail, so we don’t cover many examples.
Instead, this article focused on showing how we can create a very simple client that talks to an real world server implementation.
productivity
programming
]