智能 IDE 系列 -- SQL编辑器

智能 IDE 系列 -- SQL编辑器 豆皮粉儿们,我们又见面啦!今天我们来自字节跳动的“虫二”和“「锕」”二位同学带来了智能IDE系列文章的第一篇 —— SQL编辑器。豆皮粉儿们,赶紧来丰富自己的知识吧!

作者们: 虫二 &「锕」
来源: 原创
前言 IDE 本身是个集很多复杂功能在一起的应用,当你想开发一个IDE的时候,你至少需要关注
  1. 代码编辑器层(这部分在本文中我称为Editor层):语法高亮、智能提示&补全、语法诊断、文档悬浮、格式化...
  2. 工作目录(Workspace)
  3. 扩展层(Extension)
  4. 运行调试层(Debug)
  5. 环境配置 (Environment)
  6. 上线部署层(Publish),如果你正在做一个Cloud IDE, 这一层就是一个必备的能力,如何让用户在Web端即可实现“编辑-调试-部署”一条线,并且保证调试阶段的环境配置和部署阶段相同。
  7. 版本管理(Version)
本文主要介绍的只是以上冰山一角中的Editor层的内容,通过本文希望给正在进行相关学习的同学有些许启发,本文中每个过程不会详细解释背后技术实现原理,背后原理将在后续文章进行介绍。
如果你正好在做一个SQL Editor, 本文可以作为一个不错的参考。
本文的适用对象:
  • 你正在实现一个自己独有的Editor, 需要让Editor能实现上述1的能力,这个Editor 我认为可以是传统意义上的输入形式的Editor, 也可以是针对很多表单项填写or下拉选择的Editor,甚至于还可以是GUI 页面编辑器,其实我们只需要将语法高亮、智能提示这些在概念上做一个转换。
  • 在你的应用(未必是IDE)中需要为用户提供代码编辑的能力
  • 你正在使用一门DSL(领域专用语言)语言来简化开发的语言, 需要高亮、提示特有的语法
  • 自研一个IDE or Cloud IDE
目录
  • 从原生Web html开始解读如果做一段代码的高亮、提示
  • 开源Editor如何实现
  • LSP的诞生
  • 开源Editor组件如何与LSP对接, SQL Editor案例
  • SQL Language Server
  • 总结, 想要实现一个智能 Editor需要做哪些事情
从零开始 抛开目前已有的Editor组件,用原生html来实现高亮
例如,以Monaco 的一个例子展开 看原生如何实现
这是一段日志内容高亮规则是 日期:绿色、notice: 黄色、error: 红色、info: 灰色

语法高亮关键的步骤是词法分析, 分词的目的是将用户输入字符串分割成一个个的词 (token), token 就是不可再进一步分割的一串字符,分析过程需要扫描源代码, 扫描的方法有直接扫描和正则表达式扫描[1];
用于做分析的函数称为词法分析器
上面的案例,用正则简单粗暴实现如下,不具有任何参考意义,如果想实现复杂的分词,你应该寻找类似 flex or ANTLR这样的工具:
Highlight - 锐客网.custom-info { color: #808080 } .custom-error { color: #ff0000; font-style: bold; } .custom-notice { color: #FFA500; } .custom-date { color: #008800; }

粗暴的用一个textarea 伪代码实现简单的智能提示
例如,还是以Monaco 的一个例子展开

开源Editor是如何做的 Editor支持高亮需要两个过程
  1. 根据语法将文本解析成符号和作用域
  2. 根据生成的作用域映射到对应的颜色和样式
Editor允许你自己register一个语言id, 你需要根据token格式,编写自己的rules最终实现高亮。
然而,多数的Javascript Editor在支持智能提示上却不尽人意。
CodeMirror & Ace 需要监听change 事件来处理
editor.on('change', changeListener);

Monaco Editor在这方面做的比较前沿,允许你使用使用register provider 来注册语言特性,并且处理好了返回值的UI显示,对于使用者,不需要再单独定义UI。
例如
setMonarchTokensProvider 注册一个语言,详情
registerCompletionItemProvider 注册智能提示、
registerHoverProvider注册悬浮文档,当你处理语法解析时候,如果你不用下面的方式则需要用js 来实现一套语言的解析
LSP的诞生 从上面可以看出即使是使用同一种语言(这里我都用的javascript), 只是Editor不同而已, 实现智能提示也是需要针对单独的Editor去实现, 实际上不同语言的IDE更是需要为每个IDE都实现一遍 JavaScript 语言的智能提示。
如何为不同的IDE,提供一套通用的语言服务?
例如: Javascript 语言的server只需要有一套即可让多个IDE去使用, 这里就必须要推荐下VScode 的LSP协议(想快读的可以阅读之前写的一篇学习文章)[2], 这个协议规定了IDE和语言server之间使用规范中定义的参数格式进行通信, 协议底层交互是JSON-PRC(无状态的远程过程调用协议),在 IDE 的Client端和Server端通信的形式可以是socket, 也可以是HTTP,甚至可以是stdio。
Editor 如何与LS交互
下面以SQL 语言为案例,说明编辑器和 SQL Language Server之间如何交互
这里我在Client和Server端建立了一个Web Socket 连接

  1. 初始化: Editor打开之前 Client 会向 Server发送initialize初始化消息, 消息中params.capabilities 规定了Client端支持的能力, 比如补全
此时Server 端在接受到初始化请求后,需要发送当前语言支持的能力, 例如语言支持 documentFormattingProvider(格式化)、hoverProvider(文档悬浮)、definitionProvider(跳转定义)、completionProvider(补全) 、codeActionProvider;
如果语言不支持格式化, 就不在capabilities中返回documentFormattingProvider,client就不会显示格式化的菜单。

  1. 打开事件: Editor打开后 Client 会向Server发送textDocument/didOpen消息, 消息体如下, 会标记当前语言、源代码、uri(可以是个文件地址,也可以是个虚拟的地址,具体视Server的实现而定)
  2. change事件: 用户输入代码时,Client 会向Server发送textDocument/didChange消息, 服务端决定是否处理这个消息, 同样类似open的动作,这个案例中服务端会在输入过程中诊断语法错误,response和open 返回相同
  3. Server 也可以主动向 Client 推送事件,我这里的案例是服务端会主动发送diagnostics事件,在打开或change后发送语法诊断的结果, 诊断返回的内容是错误的文字所在位置,和错误提示,如下range 是起始和结束位置, message是消息内容
  4. 补全事件: 在输入的过程中Client 也会向Server发送textDocument/completion消息

    Server接受消息后会发送需要补全的内容,Server在内部做一系列的分析后给出需要补全内容
    比如针对用户输入的 select * from a Server需要补全库名, 当用户输入select * from aaa. 时需要补全aaa库下面的表

    这里看到 Server 响应的内容中有的会 id 字段, 该id就是Client 发送的id, Server通过此来标记响应哪个事件,Client会根据此处理对应请求的事件 原因是有些行为会多很短时间内多次触发, Client可以单独取消某次事件
    也会有写请求体和响应体中没有id的情况, 那会通过method 决定事件类型
  5. Hover文档: 鼠标悬浮单词时Client会向Server发送textDocument/hover事件, Server 根据Client发送的当前鼠标的位置计算出当前单词在抽象语法树的位置,返回对应文档

Language Server 智能提示 Language Server 需要做的,是实现 LSP 定义的功能的一个子集。这里以最为核心的智能提示为例,其需要做的事情有两步
  • 第一步当你和Editor正在交互的时候,这个时候对于Editor就是内容在change 的过程,Server 需要维护这个正在change的代码“文件”,以便在需要智能提示的时候使用。这里的实现,如果 LS 和 Editor 在同一台电脑上,大可肆意使用文件系统;如果他们分离,就需要根据change事件中的 uri 和内容来更新,并刷新到 LS 的存储中;根据 LS 声明的 capacity,每次change事件可以传递全量或增量的内容。
  • 第二步当Editor意识到此处需要一个智能提示(LS 会声明一个 triggerCharacter 使 Editor 知晓在哪些字符后需要智能提示),会发送 completion 事件到 LS,其中包含当前光标所在的位置(比如VScode 提供的位置就是lineNumbers 行, column 列 都是从1开始)。由这个位置和第一步所存储代码的内容LS会进行一系列的语法分析,返回所有可以提示出来的内容,给用户展现出来,正如在上面GIF图中你看到的下拉列表的内容框。
这个过程最关键的点在第二步,如何根据一段代码和其中的一个位置给出一系列智能提示。当然很多语言有现成的自动补全轮子,比如 Python 的 jedi。这里以 SQL 为例:简单来说,我们需要对一串 SQL 做词法分析和语法分析,以理解接下来可以写的代码是什么。这里的词法分析和语法分析,其实正是编译原理里编译器的“前端”的前半部分:词法分析是将代码切分成一个个词(Token),语法分析是对 Token 序列进行一系列定义的计算,以构建特定的数据结构。一般编译器进行语法分析后得到的产物是一颗抽象语法树(AST),并基于此继续进行语义分析并优化。一个标准SQL的AST树如下结构:

不过要实现一个智能提示,光有 AST 是不够的。首先我们需要能够支持解析正在编辑中的 SQL 代码,其次我们要将解析 SQL 的结果转换为智能提示结果。也就是说,我们需要定义详细到编辑时的语法规则,并定义语法解析时的行为使其产物携带更多对补全有用的信息。例如,我们用|代表光标,并有如下的 SQL 等待补全
SELECT | FROM some_table;

我们知道,正常来说这里需要补全*或者是some_table表下的字段,当然也可能是函数,或者是DISTINCT。所以在解析上面这段 SQL(注意这里是带着光标去解析的)后我们想要一个这样的数据结构
{ "AST": {...}, "keywords": ["*", "DISTINCT"], "columns": true, "functions": true, "source": { "table": "some_table" } }

这样我们可以通过其中的属性来得出我们提示的列表,具体的操作如下
  1. keywords列表中的内容全部进入提示的列表中
  2. functions字段为true,我们将已知的函数列表全都塞进提示的列表中
  3. columns字段为true,结合source字段得知我们需要拉取some_table表的所有字段,并放入提示的列表
当然这只是一个示例,可以按需增加解析结果中的内容,比较典型的有提示的优先级等。而具体如何将这些规则们变成一个可用的词法+语法分析器,其实由于编译器前端的发展已经很成熟了,有很多工具(parser generator)可以完成这项任务,而不需要我们对着规则手写代码逻辑,例如antlr、bison/yacc & lex 等。
关于这部分推荐阅读参考文档[1]
总结 实现一套语言的智能化,Server层你需要实现一个Language Server,这个Server可以用任何编程语言来写,vscode 提供一个符合LSP规范的包供开发者使用 vscode-languageserver [3];
如果你正在为js开发者提供一个语言服务,可以参考typescript-language-server [4];
Editor层,如果你用的是Monaco Editor 你可以在monaco-languageclient [5]的基础上来改造你想要的语言能力;
如果你用的是CodeMirror或者Ace可以参考lsp-editor-adapter [6];
参考文档
[1]词法分析
[2]LSP协议
[3]vscode-languageserver
[4]typescript-language-server
[5]monaco-languageclient
[6]lsp-editor-adapter
The End 【智能 IDE 系列 -- SQL编辑器】智能 IDE 系列 -- SQL编辑器
文章图片

    推荐阅读