🌋 WebIDE 的开发记录其六(LSP 支持)
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
一个 IDE 和编辑器很明显的使用区别就是
go to definition
go to references
的定义和引用跳转,缺少了这个功能,那么和最简单的记事本有什么区别。如果稍微深入了解 monaco-editor 之后,不满足只是简单的编辑,想要自动提示、自动补全的功能,比如 VSCode 中引入模块文件之后,可以在当前文件中看到另一个文件中的数据引用、类型定义、参数提示等。
单从这个需求上来找解决方案的话,一番搜索之后发现 monaco-editor 在一定程度上是能够支持的。
先看结果:
比如添加自定义的类型文件(可以是 d.ts 文件):
// typescript 解析配置monaco.languages.typescript.typescriptDefaults.setCompilerOptions({target: monaco.languages.typescript.ScriptTarget.ES2016,allowNonTsExtensions: true,moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,module: monaco.languages.typescript.ModuleKind.CommonJS,noEmit: true,typeRoots: ["node_modules/@types"]});// 添加额外的库支持monaco.languages.typescript.typescriptDefaults.addExtraLib(`export declare function next() : string`,'node_modules/@types/external/index.d.ts');// 打开语法检查功能monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({noSemanticValidation: false,noSyntaxValidation: false})var jsCode = `import * as x from "external"const tt : string = x.dnext();`;// model 中 uri 属性 添加 file 开头,即可在编辑器中看到对应的代码提示了monaco.editor.create(document.getElementById("container"), {model: monaco.editor.createModel(jsCode,"typescript",new monaco.Uri("file:///main.ts")),});
这个方案实现起来很简单,但是应用场景应该是考虑对于一些特定领域的输入提供支持的,比如某个 SDK 的测试界面,包含某些提示支持的简单编辑器等。
我们的目标是 IDE,不仅包含固定的 lib 支持,还需要多文件、文件模块的互相引用,比如 import 语句引入的模块。这个方法可能可以实现,但是实现起来还是很复杂的,毕竟不可能检测哪些 import 或者 require 然后再去 addExtraLib。
这个方式更加接近我们的需求,使用单例的 editor,加载全部文件的 model 实例,然后 setEagerModelSync 来启用跨 model 同步功能(贪婪模型同步?)。
单例 editor 的实现我们之前在 🌋 WebIDE 的开发记录其三(editor 集成) 中已经讲过了,用 model 和 viewState 的切换来支持多文件,这里我们只需要 setEagerModelSync 就能满足要求了。
先测试一下:
// !!!!! 设置配置(js 和 ts 都设置一下)monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true)// 默认文件的 modelconst fileModel_1 = monaco.editor.createModel(`import * as x from "./test"const tt : string = x.dnext();`,"typescript",new monaco.Uri("file:///main.ts"),)// 被引用文件的 modelconst fileModel_2 = monaco.editor.createModel("export const dnext = () => { return \"Hello World!\"; }","typescript",new monaco.Uri("file:///test.ts"), // 一定注意 file 和相对路径)// 创建单例的编辑器monaco.editor.create(document.getElementById("container"), {model: fileModel_1,});
不出意外的话应该是可以提供多文件的代码提示了,可是~~~
如果一个项目中就只有几个文件的话,效果还可以,但是如果项目中包含几十个文件呢?需要全部吧内容获取,然后创建 model 才能被解析,提供智能提示,而且好像只有 js、ts、html 之类的语言,官方 worker 才能支持。这个好像有点不太好~~~
不过至少这是一个很低成本的解决方案~~
上面说的都是代码提示的问题,也就是解析的时候能够知道引入的类型,文件之间的智能提示效果,但是如果点击转到定义功能,想要打开对应的文件,好像不可以,毕竟 monaco-editor 不知道我们怎么才能打开一个文件,包括转到定义、查看定义、查看引用、转到类型定义等。
monaco-editor 提供了一个 editorService 的功能可以让用户自己扩展一些高级功能:
// 本来是let editorOverrides = {editorService: {// openEditor: function () {// alert(`open editor called!` + JSON.stringify(arguments));// },// resolveEditor: function () {// alert(`resolve editor called!` + JSON.stringify(arguments));// }}}const defaultModel = monaco.editor.createModel('', 'plaintext');const editor = monaco.editor.create(this.el, { model: defaultModel }, editorOverrides);
其中 第三个参数 OverrideServices 目的是覆盖内置的一些服务,比如跳转到或者引用等功能,在某些版本中可能会被覆盖导致失效,可以通过相对 hack 的方式添加功能:
function hackOverides(editor: monaco.editor.IStandaloneCodeEditor) {// @ts-ignoreconst services = [...editor._instantiationService._parent._services._entries.values()];const textModelService = services.find((service) => {const props = Object.getPrototypeOf(service)return props && 'createModelReference' in props});// @ts-ignoreconst codeEditorService = editor._codeEditorService;codeEditorService.openCodeEditor = (input: { resource: monaco.Uri, options: any }) => {const { resource, options } = inputconst file = resource.path;const range = options.selection;// Add code here to open the code editor.const states = window._novus.models.workspace.statelet path = file.replace(states.config.path, '')commandsPool.writer.openFile.trigger(path, () => {window._novus.models.writer.state.currentEditor.setSelection(range);window._novus.models.writer.state.currentEditor.revealLineInCenter(range.startLineNumber);});}textModelService.createModelReference = async (uri: monaco.Uri) => {const fileManagerModel = window._novus.models["fileManager"]const states = window._novus.models.workspace.statelet fileId = uri.path.replace(states.config.path, '')let model = modelsHolder.models[fileId];if (!model) {let file = await fileManagerModel.actions.queryFile(fileId);const contentsMap = fileManagerModel.state.contentsMapconst content = contentsMap[fileId];const language = getLang(`.${fileId.split('.').pop()}`)if (file) {await fileManagerModel.actions.fetchFileContent(fileId);modelsHolder.addModel(file.relative, file.path, content, language);model = modelsHolder.models[fileId];}}const reference = {load() {return Promise.resolve(model.model)},// in my case, I have nothing to dispose as I just re-use existing resourcesdispose() { },textEditorModel: model.model}return {object: reference,dispose() { },};};}hackOverides(editor);
跳转和引用查看之后,智能提示就比较完整了
上面说了 monaco-editor 如果要完整的代码提示,需要将全部依赖文件加载到本地,这个对于简单项目或者固定的文件可以操作,但是对于我们 IDE 项目来说是很不现实的。
其实编辑器的方向里面,有 LSP(Language Server Protocol) 的概念:
The Language Server Protocol (LSP) defines the protocol used between an editor or IDE and a language server that provides language features like auto complete, go to definition, find all references etc. The goal of the Language Server Index Format (LSIF, pronounced like "else if") is to support rich code navigation in development tools or a Web UI without needing a local copy of the source code.
翻一下就是定义了一个语言服务和编辑器直接的协议,而语言服务能够提供自动完成、跳转定义、引用查看等功能,这个功能能让语言的支持能力和编辑器分开,所以编辑器不再需要每个语言都要开发语法解析等繁杂的功能,新出的语言也能很快的适应。
其中语言服务器目前已经有很多的实现了 查看列表:
Language | Maintainer | Repository | Implementation Language |
---|---|---|---|
... | ... | ... | |
Go | Go Team | gopls | Go |
GraphQL | GraphQL community | Server in GraphQL | JavaScript |
Groovy | Palantir | groovy-language-server | Java |
HTML | MS | vscode-html-languageserver | TypeScript |
JSON | MS | vscode-json-languageserver | TypeScript |
Java | @georgewfraser | Java Compiler (javac) API-based Java support | Java |
Javascript Flow | Facebook, Inc. | flow | OCaml, JavaScript |
Javascript-Typescript | Sourcegraph | javascript-typescript | TypeScript |
... | ... | ... |
其中 monaco-editor 也有客户端的支持 monaco-languageclient,也就是可以使用 LSP 的。
Editor/IDE | Maintainer | Repository | |
---|---|---|---|
Visual Studio Code | Microsoft | vscode | |
Eclipse Che | Eclipse,Codenvy/TypeFox | Che | |
Eclipse IDE | Eclipse,Red Hat | Eclipse community, Eclipse LSP4E | |
emacs | Vibhav Pant | emacs language server client | |
* | MS Monaco Editor | TypeFox | monaco-languageclient |
Visual Studio | Microsoft | LSP Preview | |
Atom | GitHub | atom-languageclient | |
Theia | theia-ide | theia | |
Sublime Text | Raoul Wols | lsp | |
... | ... | ... |
服务端我们使用的 node.js,也有 SDK 的支持:vscode-languageserver-node
Language | Maintainer | Repository | |
---|---|---|---|
* | node.js | MS | vscode-languageserver-node |
C# | MS | work in progress by David Wilson | |
Java | Eclipse, TypeFox | Eclipse LSP4J | |
PHP | Felix Becker | php-language-server | |
... | ... | ... |
所以结论:有很多现成的语言服务、有现成的 LSP 客户端支持、有现成的服务端协议。所以能够实现 LSP 的 monaco-editor 支持没什么问题。
里面的一些封装和 dispose 之类的因为篇幅没有写,实际上都需要考虑,而且我在实际项目中还考虑了:多语言配置、同时运行多语言、同一个 socket.io 通道传输、重连、dispose、错误显示、一些边界情况的处理转换等等,封装逻辑相对多一些,下面说的都是一些简化的逻辑,也并没有严格测试。
- 协议:vscode-ws-jsonrpc(一套 json 格式标准的 rpc 通信协议)、WS 服务用来传输
- 语言服务:typescript-language-server(ts 为例,使用 typescript-language-server 作为 SDK 开发)
- 服务端:项目工作目录、vscode-ws-jsonrpc、进程运行
- 客户端:monaco-editor、monaco-languageclient、vscode-ws-jsonrpc
- 数据流向:editor -> languageclient -> jsonrpc -> ws -> jsonrpc -> ts language-server -> languageserver SDK
使用 socket.io 作为 jsonrpc 格式数据的传输通道,也就是 language client 和 language server 的双向数据交流载体。
const os = require('os');const app = require('express')();const http = require('http').createServer(app);const io = require('socket.io')(http);// 通过 socket.io 来输入输出io.on('connection', (socket) => {socket.on('init_lang_server', (msg) => {// ... start the language server});// ...});// 开启 serverapp.get('/', (req, res) => {res.sendFile(__dirname + '/index.html');});http.listen(3000, () => {console.log('listening on *:3000');});
下面的代码是将 websocket 使用到 jsonrpc 协议中的逻辑,jsonrpc 自带的:
import { Socket } from "socket.io";import * as rpc from "vscode-ws-jsonrpc"import * as server from "vscode-ws-jsonrpc/lib/server"const { WebSocketMessageReader, WebSocketMessageWriter } = rpc;const { createConnection } = server;const socket: rpc.IWebSocket = adapterWS(rawWSSocket)// websocket io 转换const reader = new WebSocketMessageReader(socket);const writer = new WebSocketMessageWriter(socket);// 将读取写入封装成数据连接以供后续使用const socketConnection = createConnection(reader, writer);// 将 socket.io 的 ws 转为 rpc 支持的 ws(!!!此处是简化的逻辑)function adapterWS(rawWSSocket: Socket): rpc.IWebSocket {return {send: content => {rawWSSocket.emit('data', {language, content})},onMessage: cb => {rawWSSocket.on('message', (data: any) => cb(data.content))},onError: cb => {rawWSSocket.on('error', (data: any) => cb(data))},onClose: cb => {rawWSSocket.on('close', (code: number, reason: string) => cb(code, reason))},dispose: () => {console.error(`[lsp]<${wsName}>{${language}} fakeSocket disconnect`)}}}
创建一个语言服务进程作为服务,拿到输入输出部分,以 ts 为例:
const { createStreamConnection } = server;// 定义运行参数const tsServerProcessOptions = {command: "node",args: [path.resolve(__dirname, "./langs/typescript.js"), "--stdio"], // , ' --tsserver-log-file=ts-logs.txt', '--log-level=3'options: {}}// 使用 child_process 运行一个单独的语言处理进程let rawProcess: child_process.ChildProcess = child_process.spawn(tsServerProcessOptions.command,tsServerProcessOptions.args,tsServerProcessOptions.options);// 根据进程的输入输出创建连接const streamConnection = createStreamConnection(rawProcess.stdout, rawProcess.stdin)
其中
./langs/typescript.js
就一行简单的代码:require('typescript-language-server/lib/cli');
至此 有了基于 ws 数据 rpc 通道,有了 进程的输入输出数据,现在只需要粘在一起:
import * as rpc from "vscode-ws-jsonrpc"const { forward } = server;// 将服务进程的输入输出转发给 socket 的输入输出forward(socketConnection, streamConnection, message => {if (rpc.isRequestMessage(message)) {// console.log('请求消息: ');// 修正 server 运行参数(moncao-languageclient 和 lsp 参数不匹配)// lsp 初始化请求if (message.method === lsp.InitializeRequest.type.method) {message.params.processId = process.pid;message.params.initializationOptions = {}}if (message.method === lsp.FoldingRangeRequest.type.method) {message.params.update = function (i: any, v: any) {console.log(i, v)}}// if (message.method === lsp.DidOpenTextDocumentNotification.type.method) {// message.params.create// }}else if (rpc.isResponseMessage(message)) {}else if (rpc.isNotificationMessage(message)) {} else {}return message;});console.log(`[lsp] 工作区语言服务开启成功`)
一个
vscode-ws-jsonrpc
几乎就将服务端的全部逻辑解决了,非常清楚、也很简单。客户端的实现也不是很复杂,先是基本的处理操作:
import {MonacoLanguageClient, CloseAction, ErrorAction,MonacoServices, createConnection, ProtocolToMonacoConverter,} from 'monaco-languageclient';// 一套 json 格式标准的 rpc 通信协议import { listen, MessageConnection, createWebSocketConnection, ConsoleLogger, Message } from 'vscode-ws-jsonrpc';// 设置工作根目录const rootUri = `file:///workspace/`;// 在编辑器上添加语言服务MonacoServices.install(editor, { rootUri });
import io from 'socket.io-client';// lsp 的 ws 地址,创建 jsonrpc 的 ws 实例与服务器通信const lspUrl = 'ws://127.0.0.1:7001/lsp';// ws 配置const connectOpts: SocketIOClient.ConnectOpts = {query: {name: window.currentProjectName,},transports: ['websocket'],reconnection: true,};// 全局的 socket 客户端(可以拓展重连、多语言服务通道复用等)const globalSocketIOClient = io(lspUrl, connectOpts);
每个语言客户端与 server 相连需要一个 jsonrpc 的 ws 通道。这里使用 ws 搭建出一个 messageConnection
let messageConnection: MessageConnection = createWebSocketConnection({send: content => {globalSocketIOClient.emit('message', { language, content });},onMessage: cb => {connectionsMap[language].onMessageCallback.push(cb);},onError: cb => {connectionsMap[language].onError.push(cb);},onClose: cb => {connectionsMap[language].onClose.push(cb);},dispose: () => {globalSocketIOClient.close()}}, new ConsoleLogger());
通过 MonacoLanguageClient 创建语言客户端:
const languageClient = new MonacoLanguageClient({'typescript',clientOptions: {documentSelector: ['typescript', 'javascript'],outputChannel: {append(value: any) {console.log(value)},appendLine(line: any) {console.log(line)},show(any: any) {console.log(any)},dispose() {// no-op}},errorHandler: {error: (error: Error, message: Message, count: number) => {console.log(error, message, count)return ErrorAction.Continue},closed: () => {console.log('closed')return CloseAction.DoNotRestart}},},// create a language client connection from the JSON RPC connection on demandconnectionProvider: {get: (errorHandler, closeHandler) => {const func = createConnection(connection, errorHandler, closeHandler);return Promise.resolve(func);}}});
// 启动本地的语言客户端languageClient.onReady().then(() => {lspOutputer.log(`本地 ${languageid} 语言客户端准备完毕.`)});languageClient.start();// 告诉服务端启动对应的语言服务globalSocketIOClient.emit('initLang', { ws: window.currentProjectName, language: languageid });
最后能得到一个让人很激动的效果,不用加载文件内容等笨方法,就能感知语言的各种服务:
LSP 的概念不是那么复杂,但是一整套下来也涉及到很多深层的概念,如果不是有前人的探索,比如 VSCode、languageClient 等实现,这块将会是一块很难啃的骨头,好在有 VSCode 这种前端优秀编辑器的探索和实现,在 moncao-editor 上也沾了很多光,实现了很惊奇的效果。
整篇文章的逻辑和代码都是在我项目中截取的,大概率是没办法直接拿来运行,不过其中的流程和关键位置都提及了,如果不嫌弃有交流的需要可以在 关于我 中联系我~~
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/workpad-part-6)