如何创建集成 LSP 支持多语言的 Web 代码编辑器

对于一个云开发平台来说,一个好的 Web IDE 能很大程度地提高用户的编码体验,而一个 Web IDE 的一个重要组成部分就是代码编辑器。

目前有着多款 web 上的代码编辑器可供选择,比如 AceCodeMirrorMonaco,这三款编辑器的比较在这篇文章中有着详细的介绍,在此就不作过多赘述。这篇文章我们选择 Monaco Editor 来对 LSP 进行集成,从而在理论上能够支持所有的编程语言。

原文链接:https://forum.laf.run/d/1027

什么是 LSP

LSP(Language Server Protocol),也就是语言服务协议,更具体更通俗地说就是定义了在代码编辑器和语言服务器之间的一套规范,从而让原本

m 个编辑器与 n 个编程语言之间的对应关系

变为

m 个编辑器与 LSP 的关系和 n 个编程语言与 LSP 之间的关系,

从而将开发的复杂度由 m*n 降到了 m+n

除了对编辑器开发者和编程语言开发者友好,对我们这种尝试让一个编辑器支持多种语言的开发者也更是友好,有 vscode 这样的编辑器珠玉在前,便能轻松地根据 vscode 的设计思路实现我们的需求。

预览

在这篇文章中,我们会开发一个最小最轻量的编辑器 Demo 作为演示,架构非常简单,就是前端创建一个 Monaco Editor,后端创建一个语言服务器,二者之间通过 vscode-ws-jsonrpcWebSocket 服务进行传输,实际实现的 WebPython 编辑器如下:

Server 端开发

Web 端能接入语言服务前,我们得先在服务端运行一个语言服务,https://langserver.org/ 这个网站收录了许多语言服务的实现,

这里我们选择微软官方维护的 pyright 提供语言服务

首先创建 Express 服务器,配置静态文件服务,使用 fileURLToPathdirname 来获取当前文件的路径,并将服务设置在 30000 端口

const app = express();
const __filename = fileURLToPath(import.meta.url);
const dir = dirname(__filename);
app.use(express.static(dir));
const server = app.listen(30000);

然后我们需要创建一个 WebSocket Server,注意这里的 noServer 参数,如果没有指定 noServer,那么 WebSocketServer 会自动创建一个 http server 来处理浏览器的 HTTP 请求到 WebSocket 请求的 upgrade。

const wss = new WebSocketServer({
	noServer: true,
});

而这里我们需要创建自己的 HTTP 服务器,并手动处理浏览器的 upgrade 请求。下面代码便是如何监听 upgrade 事件并进行处理。

server.on('upgrade',()=>{});

在处理函数中,按照下面的代码将 WebSocket 使用到 jsonrpc 协议中,并启动语言服务器让二者相连。

先构建语言服务器的路径,找到 pyright 包所在的位置。

const baseDir = resolve(getLocalDirectory(import.meta.url));
const relativeDir = '../../../node_modules/pyright/dist/pyright-langserver.js';
const ls = resolve(baseDir, relativeDir); 

再创建语言服务器的连接 和 创建 WebSocket 的数据连接

const serverConnection = createServerProcess(serverName, 'node', [ls, '--stdio']);
const reader = new WebSocketMessageReader(socket);
const writer = new WebSocketMessageWriter(socket);
const socketConnection = createConnection(reader, writer, () => socket.dispose());

最后用 forward 函数将消息从 soketConnection 转发到 serverConnection,如下:

forward(socketConnection, serverConnection, message => {
    if (Message.isRequest(message)) {
        console.log(`Received:`);
        console.log(message);
	}
    if (Message.isResponse(message)) {
        console.log(`Sent:`);
        console.log(message);
    }
	return message;
});

于是我们将语言服务器跑起来,并在文章后面阶段会写的前端编辑器中随便输入一点东西,可以看到终端里输出了 message,

这样我们就使用 pyright 完成了语言服务器的开发。

Web 端开发

接下来我们开发前端的内容,还是在上面的那个网站中,

可以看到 Monaco Editor 也是有支持 LSP 的方案的,所以我们使用 TypeFox 开发的 monaco-languageclient 对monaco 进行集成 lsp 的开发。

首先使用 monaco-languageclient 的 initServices 函数初始化一些服务,其中最重要的就是下面四个配置,定义了 Monaco 的语言服务与主题显示。

await initServices({
    enableModelService: true,
    enableThemeService: true,
    enableTextmateService: true,
    enableLanguagesService: true,
})

然后创建能够与语言服务器相连的 WebSocket 连接。

createWebSocket("ws://localhost:30000/pyright");

而创建这个连接也需要用到 monaco-languageclient。

const createWebSocket = (url: string): WebSocket => {
    const webSocket = new WebSocket(url);
    webSocket.onopen = async () => {
        const socket = toSocket(webSocket);
        const reader = new WebSocketMessageReader(socket);
        const writer = new WebSocketMessageWriter(socket);
        languageClient = createLanguageClient({
            reader,
            writer
        });
        await languageClient.start();
        reader.onClose(() => languageClient.stop());
    };
    return webSocket;
};

const createLanguageClient = (transports: MessageTransports): MonacoLanguageClient => {
    return new MonacoLanguageClient({
        name: 'Pyright Language Client',
        clientOptions: {
            documentSelector: [languageId],
            errorHandler: {
                error: () => ({ action: ErrorAction.Continue }),
                closed: () => ({ action: CloseAction.DoNotRestart })
            },
            workspaceFolder: {
                index: 0,
                name: 'workspace',
                uri: monaco.Uri.parse('/tmp')
            },
            synchronize: {
                fileEvents: [vscode.workspace.createFileSystemWatcher('**')]
            }
        },
        connectionProvider: {
            get: () => {
                return Promise.resolve(transports);
            }
        }
    });
};

接下来这里需要创建一个虚拟文件系统,作为 Monaco Editor 实例的输入输出。

const fileSystemProvider = new RegisteredFileSystemProvider(false);
fileSystemProvider.registerFile(new RegisteredMemoryFile(vscode.Uri.file('/test.py'), 'print("Hello, laf!")'));
registerFileSystemOverlay(1, fileSystemProvider);
const modelRef = await createModelReference(monaco.Uri.file('/test.py'));

最后创建 Monaco Editor 实例即可,还能进行些许的配置。

createConfiguredEditor(document.getElementById('container')!, {
    model: modelRef.object.textEditorModel,
    automaticLayout: true,
    minimap: {
    	enabled: false
    },
    scrollbar: {
        verticalScrollbarSize: 4,
        horizontalScrollbarSize: 8,
    },
    overviewRulerLanes: 0,
    lineNumbersMinChars: 4,
    scrollBeyondLastLine: false,
    theme: 'vs',
});

就这样我们也完成了前端。

import Editor from './python/Editor';

function App() {
  
  return (
    <>
      <h2>monaco python lsp</h2>
      <div style={{height:"500px", width:"800px", border:"1px solid black", padding:"8px 0"}}>
        <Editor />
      </div>
    </>
  )
}

export default App

效果如下

小结

要深入理解 LSP 以及其背后的工作原理还是有很大的难度的,但是好在有 languageserver,languageclient 这类优秀的开源项目提供支持,能够让我们在仅仅拼凑了几段代码后拥有不错的代码编辑器效果。下一步计划用 LSP 改造 Laf 的 Web IDE。

由于我也只是刚刚接触这块知识,文章中难免有错漏,希望能与读到这里的各位共同交流进步。

参考资料

posted @ 2023-09-11 11:31  米开朗基杨  阅读(1896)  评论(2编辑  收藏  举报