最小的语言服务器实现

为了更好的理解语言服务器协议,我们不使用任何第三方库实现一个语言服务器客户端和服务端,并尽可能最小。

语言服务器可以使用多种通信方式,我们采用最常用的一种: stdio, 即基于标准输入输出进行通信

我们创建一个项目,名为 min-lsp-example 的 js 语言项目。

mkdir min-lsp-example
cd min-lsp-example
npm init -y

创建一个 client.js 文件, 并写入以下内容:

const { spawn } = require('child_process');
const ls = spawn('node', ['server.js']);

ls.stdin.write("client init message")

ls.stdout.on('data', (data) => {
    console.log(`stdout: ${data}`);
});

我们在 client.js 中使用 server.js 创建了一个子进程,并向子进程的标准输入中写入了一些字符,并接受来自子进程的输出,然后在收到的消息前加上 stdout: 后将其输出到了控制台。当然,server.js 并不存在,因此,创建一个 server.js 文件,并写入以下内容:

process.stdin.on("data", data => {
    data = data.toString().toUpperCase()
    process.stdout.write(data)
})

我们在 server.js 中我们接受来自标准输入的内容,并将其转换为大写后,写入标准输出。

现在我们运行它:

node client.js

我们看到控制台输出了如下信息:

stdout: CLIENT INIT MESSAGE

这表明,我们成功创建了一个进程,并使用标准输入输出与其通信,下面,我们将他们分别改造为语言服务器的客户端和服务端。

客户端和服务端必须的能力

  • 客户端和服务器都必须支持生命周期消息: initialize 请求, initialized 通知, shutdown 请求, exit 通知

  • 客户端必须实现 textDocument/didOpentextDocument/didChangetextDocument/didClose 通知

我们首先创建一个基础协议模块来处理,请求、响应和通知,创建 protocol.js 文件,并写入以下内容:

let id = 0;

function getId() {
    return id;
}

function getMessage(data) {
    let json = JSON.stringify(data);
    let length = json.length;
    return `Content-Length: ${length}\r\n\r\n${json}`;
}

function getRequest(method, params) {
    id++;
    return getMessage({
        jsonrpc: "2.0",
        id,
        method,
        params
    });
}

function getResponse(id, result, error) {
    return getMessage({
        jsonrpc: "2.0",
        id,
        result,
        error
    })
}

function getNotification(method, params) {
    return getMessage({
        jsonrpc: "2.0",
        method,
        params
    })
}

module.exports = {
    getId,
    getRequest,
    getResponse,
    getNotification,
}

客户端向服务器发送 initialize 请求,根据 initialize 请求 中的 请求(Request) 定义,并统一处理发送数据和处理数据,对 client.js 进行修改:

const { spawn } = require('child_process');
+const protocol = require('./protocol');
const ls = spawn('node', ['server.js']);

-ls.stdin.write("client init message")
+send(protocol.getRequest("initialize", {
+    processId: process.pid,
+    rootUri: null,
+    capabilities: {}
+}))

ls.stdout.on('data', (data) => {
-    console.log(`stdout: ${data}`);
+    dealMessage(data.toString())
});
+
+function send(data) {
+    console.log("===>", data.split("\r\n\r\n")[1]);
+    ls.stdin.write(data)
+}
+
+function dealMessage(data) {
+    console.log(data)
+}

根据 响应(Response) 的定义,对 server.js 进行修改,并且考虑不同客户端的实现导致消息标头和内容不连续的可能:

+const protocol = require('./protocol');
+
+let length = 0
+
process.stdin.on("data", data => {
-    data = data.toString().toUpperCase()
-    process.stdout.write(data)
+    data = data.toString()
+    let arr = data.split("Content-Length: ")
+    if (arr[0] && arr[0].length === length) {
+        dealMessage(arr[0])
+    }
+
+    for (let i = 1; i < arr.length; i++) {
+        let value = arr[i].split("\r\n\r\n")[1]
+        if (value) {
+            dealMessage(value)
+        } else {
+            length = Number(arr[i].split("\r\n\r\n")[0])
+        }
+    }
})
+
+function dealMessage(data) {
+    let message = JSON.parse(data)
+    if (message.method === 'initialize') {
+        process.stdout.write(protocol.getResponse(message.id, {
+            capabilities: {}
+        }))
+    }
+}

现在启动客户端:

node client.js

我们可以看下以下输出:

===> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":49654,"rootUri":null,"capabilities":{}}}
Content-Length: 53

{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}

这是包括发送的 initialize 请求和从服务器返回的对 initialize 请求的响应消息。

根据协议,客户端在收到 initialize 请求的结果之后,向服务器发送 initialized 通知,同样考虑不同服务端的实现导致消息标头和内容不连续的可能,修改 client.js:

const { spawn } = require('child_process');
const protocol = require("./protocol")
const ls = spawn('node', ['server1.js']);

send(protocol.getRequest("initialize", {
    processId: process.pid,
    rootUri: null,
    capabilities: {}
}))

+let length = 0
+
ls.stdout.on('data', (data) => {
-    dealMessage(data.toString())
+    data = data.toString()
+    let arr = data.split("Content-Length: ")
+    if (arr[0] && arr[0].length === length) {
+        dealMessage(arr[0])
+    }
+
+    for (let i = 1; i < arr.length; i++) {
+        let value = arr[i].split("\r\n\r\n")[1]
+        if (value) {
+            dealMessage(value)
+        } else {
+            length = Number(arr[i].split("\r\n\r\n")[0])
+        }
+    }
});

function send(data) {
    console.log("===>", data.split("\r\n\r\n")[1]);
    ls.stdin.write(data)
}

function dealMessage(data) {
-    console.log(data);
+    let response = JSON.parse(data)
+    console.log("<===", data);
+    if (response.id === 1) {
+        send(protocol.getNotification("initialized", {}))
+    }
}

现在,启动客户端,我们可以看到:

===> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":49975,"rootUri":null,"capabilities":{}}}
<=== {"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}
===> {"jsonrpc":"2.0","method":"initialized","params":{}}

客户端收到 initialize 的响应之后向服务器发送了 initialized 通知。我们成功完成了客户端和服务器的初始化。

这里,我们客户端不需要做任何操作,因此,我们在等待 1s 后发送 shutdown 请求,修改 client.js:

function dealMessage(data) {
    let response = JSON.parse(data)
    console.log("<===", data);
    if (response.id === 1) {
        send(protocol.getNotification("initialized", {}))
+        setTimeout(() => {
+            send(protocol.getRequest("shutdown"))
+        }, 1000);
    }
}

在服务端接收 shutdown 请求,并设置 shutdown 状态,修改 server.js:

+let shutdown = false;
function dealMessage(data) {
    let message = JSON.parse(data)
    if (message.method === 'initialize') {
        process.stdout.write(protocol.getResponse(message.id, {
            capabilities: {}
        }))
+    } else if (message.method === 'shutdown') {
+        shutdown = true;
+        process.stdout.write(protocol.getResponse(message.id, null))
    }
}

客户端收到 shutdown 请求的响应之后,发送 exit 通知,使服务器退出,修改 client.js:

+ls.on('close', (code) => {
+    console.log(`child process exited with code ${code}`);
+});
+
+let shutdownId = 0;
+
function dealMessage(data) {
    let response = JSON.parse(data)
    console.log("<===", data);
    if (response.id === 1) {
        send(protocol.getNotification("initialized", {}))
        setTimeout(() => {
            send(protocol.getRequest("shutdown"))
+            shutdownId = protocol.getId();
        }, 1000);
+    } else if (response.id === shutdownId) {
+        send(protocol.getNotification("exit"))
    }
}

服务器接收到 exit 通知后退出,修改 server.js:

function dealMessage(data) {
    let message = JSON.parse(data)
    if (message.method === 'initialize') {
        process.stdout.write(protocol.getResponse(message.id, {
            capabilities: {}
        }))
    } else if (message.method === 'shutdown') {
        shutdown = true;
        process.stdout.write(protocol.getResponse(message.id, null))
+    } else if (message.method === 'exit') {
+        if (shutdown) {
+            process.exit(0)
+        } else {
+            process.exit(1)
+        }
    }
}

最终代码如下:

client.js

const { spawn } = require('child_process');
const protocol = require("./protocol")
const ls = spawn('node', ['server.js']);

send(protocol.getRequest("initialize", {
    processId: process.pid,
    rootUri: null,
    capabilities: {}
}))

let length = 0

ls.stdout.on('data', (data) => {
    data = data.toString()
    let arr = data.split("Content-Length: ")
    if (arr[0] && arr[0].length === length) {
        dealMessage(arr[0])
    }

    for (let i = 1; i < arr.length; i++) {
        let value = arr[i].split("\r\n\r\n")[1]
        if (value) {
            dealMessage(value)
        } else {
            length = Number(arr[i].split("\r\n\r\n")[0])
        }
    }
});

ls.on('close', (code) => {
    console.log(`child process exited with code ${code}`);
});

function send(data) {
    console.log("===>", data.split("\r\n\r\n")[1]);
    ls.stdin.write(data)
}

let shutdownId = 0;

function dealMessage(data) {
    let response = JSON.parse(data)
    console.log("<===", data);
    if (response.id === 1) {
        send(protocol.getNotification("initialized", {}))
        setTimeout(() => {
            send(protocol.getRequest("shutdown"))
            shutdownId = protocol.getId();
        }, 1000);
    } else if (response.id === shutdownId) {
        send(protocol.getNotification("exit"))
    }
}

server.js

const protocol = require("./protocol");

let length = 0

process.stdin.on("data", data => {
    data = data.toString()
    let arr = data.split("Content-Length: ")
    if (arr[0] && arr[0].length === length) {
        dealMessage(arr[0])
    }

    for (let i = 1; i < arr.length; i++) {
        let value = arr[i].split("\r\n\r\n")[1]
        if (value) {
            dealMessage(value)
        } else {
            length = Number(arr[i].split("\r\n\r\n")[0])
        }
    }
})

let shutdown = false;

function dealMessage(data) {
    let message = JSON.parse(data)
    if (message.method === 'initialize') {
        process.stdout.write(protocol.getResponse(message.id, {
            capabilities: {}
        }))
    } else if (message.method === 'shutdown') {
        shutdown = true;
        process.stdout.write(protocol.getResponse(message.id, null))
    } else if (message.method === 'exit') {
        if (shutdown) {
            process.exit(0)
        } else {
            process.exit(1)
        }
    }
}

现在,我们运行它,会显示如下消息:

===> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":51807,"rootUri":null,"capabilities":{}}}
<=== {"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}
===> {"jsonrpc":"2.0","method":"initialized","params":{}}
===> {"jsonrpc":"2.0","id":2,"method":"shutdown"}
<=== {"jsonrpc":"2.0","id":2,"result":null}
===> {"jsonrpc":"2.0","method":"exit"}
child process exited with code 0

到这里(忽略客户端对 textDocument/* 的处理),我们成功实现了一个语言服务器的客户端和服务端,尽管它们没有任何能力。

这里,特别感谢 connie1451 的帮助!