词法、语法、语义分析概念都属于编译原理的前端领域,而此次的目的是作 具有完善语法提示的 SQL 编辑器,只需用到编译原理的前端部分。前端
通过连续几期的介绍,《手写 SQL 编译器》系列进入了 “智能提示” 模块,前几期从 词法到文法、语法,再到构造语法树,错误提示等等,都是为 “智能提示” 作准备。git
因为智能提示须要对词法分析、语法分析作深度定制,因此咱们没有使用 antlr4 等语法分析器生成工具,而是创造了一个 JS 版语法分析生成器 syntax-parser。github
此次一口气讲完如何从 syntax-parser 到作一个具备智能提示功能的 SQL 编辑器。算法
从语法解析、智能提示和 SQL 编辑器封装三个层次来介绍,这三个层次就像俄罗斯套娃同样具备层层递进的关系。sql
为了更清晰展示逻辑层次,同时知足解耦的要求,笔者先从智能提示总体设计架构讲起。typescript
syntax-parser 是一个 JS 版的语法分析器生成器,除了相似 antlr4 基本语法分析功能外,还支持专门为智能提示优化的功能,后面会详细介绍。总体架构设计以下图所示:json
sql lexer
与 sql parser
。sql parser
基础之上编写一套 sql reader
,包含了一些分析函数解析语法树的语义。sql reader
封装 monaco-editor 插件,同时实现 用户 <=> 编辑器 间的交互,与 编辑器 <=> 语义分析器 间的交互。syntax-parser 分为词法分析、语法分析两步。词法分析主要利用正则构造一个有穷自动机,你们都学过的 “编译原理” 里有更完整的解读,或者移步 精读《手写 SQL 编译器 - 词法分析》,这里主要介绍语法分析。缓存
词法分析的输入是语法分析输出的 Tokens。Tokens 就是一个个单词,Token 结构存储了单词的值、位置、类型。性能优化
咱们须要构造一个执行链条消费这些 Token,也就是能够执行文法扫描的程序。咱们用四种类型节点描述文法,以下图所示:架构
若是不了解文法概念,能够阅读 精读《手写 SQL 编译器 - 文法介绍》
能消耗 Token 的只有 MatchNode 节点,ChainNode 节点描述前后关系(好比 expr -> name id),TreeNode 节点描述并列关系(好比 factor -> num | id),FunctionNode 是函数节点,表示还未展开的节点(若是把文法匹配比作迷宫探险,那这是个无限迷宫,没法穷尽展开)。
如何用 syntax-parser 描述一个文法,能够访问文档,如今咱们已经描述了一个文法树,应该如何解析呢?
咱们先找到一个非终结符做为根节点,深度遍历全部非终结符节点,遇到 MatchNode 时若是匹配,就消耗一个 Token 并继续前进,不然文法匹配失败。
遇到 ChainNode 会按照顺序执行其子节点;遇到 FunctionNode(非终结符节点)会执行这个函数,转换为一个非 FunctionNode 节点,以下图所示:
遇到 TreeNode 节点时保存这个节点运行状态并继续执行,在 MatchNode 匹配失败时能够还原到此节点继续尝试下个节点,以下图所示:
这样就具有了最基本的语法分析功能,如需更详细阅读,能够移步 精读《手写 SQL 编译器 - 语法分析》。
咱们还作了一些优化,好比 First 集优化与路径缓存优化。限于篇幅,分布在如下几篇文章:
SQL 编辑器重点在于如何作输入提示,也就是如何在用户光标位置给出恰当的提示。这就是咱们定制 SQL 编辑器的缘由,输入提示与语法检测须要分开来作,而语法树并不能很好解决输入提示的问题。
为了找到一个较为完美的语法提示方案,经过查阅大量资料,我决定将光标做为一个 Token 考虑来实现智能提示。
咱们用 |
表示光标所在位置,那么下面的 SQL 应该如何处理?
select | from b;
复制代码
你会发现,从语法和提示角度来看同一个输入,结果每每是矛盾的,因此咱们须要分两条线程分别处理语法与提示。
但输入错误时,咱们是没法构造语法树的,而智能提示的时机每每都是语句语法错误的时机,用过 AST 工具的人都知道。但是没有语法树,咱们怎么作到智能的提示呢?试想以下语句:
select c.| from (
select * from dt;
) c;
复制代码
面对上面这个语句,很显然 c.
没有写完,通常的语法树解析器提示你语法错误。你可能想到这几种方案:
通常咱们会采起第二种方案,看上去相对靠谱。处理过程是这样的:
select c.$my_custom_symbol$ from ...
复制代码
以后在 AST 中找到 $my_custom_symbol$
字符串,对应的节点就是光标位置。实际上这能够解决大部分问题,除了关键字。
这种方案惟有关键字场景不兼容,试想一下:
select a |from b;
# select a $my_custom_symbol$ b;
复制代码
你会发现,“补全光标文字” 法,在关键字位置时,会把本来正确的语句变成错误的语句,根本解析不出语法树。
咱们在 syntax-parser 解析引擎层就解决了这个问题,解决方案是 连同光标位置一块儿解析。
咱们作两个基本假设:
关键字:
所以针对第一种假设,syntax-parser 内置了 “关键字提示” 功能。由于 syntax-parser 能够拿到你配置的文法,所以当给定光标位置时,能够拿到当前位置前一个 Token,经过回溯和平行尝试,将后面全部可能性提示出来,以下图:
输入是 select a |
,灰色部分是已经匹配成功的部分,而咱们发现光标位置前一个 Token 正是红色标识的 word
,经过尝试运行推导,咱们发现,桔红色标记的 ','
和 'from'
都是 word
可能的下一个肯定单词,这种单词就是 SQL 语法中的 “关键字”,syntax-parser 会自动告诉你,光标位置可能的输入是 [',', 'from']
。
因此关键字的提示已经在 syntax-parser 层内置解决了!并且不管语法正确与否,都不影响提示结果,由于算法是 “寻找光标位置前一个 Token 全部可能的下一个 Token”,这能够彻底由词法分析器内置支持。
非关键字:
针对非关键字,咱们解决方案和用特殊字符串补充相似,但也有不一样:
所以 syntax-parser 老是返回两个 AST 信息:
{
"ast": {},
"cursorPath": []
}
复制代码
分别是语法树详细信息,与光标位置在语法树中的访问路径。
对于 select a |
的状况,会生成三个 Tokens:['select', 'a', 'cursor']
,对于 select a|
的状况,会生成两个 Tokens:['select', 'a']
,也就是光标与字符相连时,不会覆盖这个字符。
cursorPath
的生成也比 “字符串补充” 方案更健壮,syntax-parser 生成的 AST 会记录每个 Token 的位置,最终会根据光标位置进行比对,进而找到光标对应语法树上哪一个节点。
对 .| 的处理:
可能你已经想到了,.|
状况是很通用的输入场景,好比 user.
但愿提示出 user
对象的成员函数,或者 SQL 语句表名存在项目空间的状况,可能 tableName 会存在 .|
的语法。
.|
情况时,语法是错误的,此时智能提示会遇到挑战。根据查阅的资料,这块也有两种常见处理手法:
.
位置加上特殊标识,让语法解析器能够正确解析出语法树。.
,先让语法正确解析,再分析语法树拿到 .
前面 Token 的属性,推导出后面的属性。然而这两种方式都不太优雅,syntax-parser 选择了第三种方式:隔空打牛。
经过抽象,咱们发现,不管是 user.name
仍是 udf:count()
这种语法,都要求在某个制定字符打出时(好比 .
或 :
),提示到这个字符后面跟着的 Token。
此时光标焦点在 .
而非以后的字符上,**那咱们何不将光标偷偷移到 .
以后,进行空光标 Token 补位呢!**这样不但能彻底复用以前的处理思想,还能够拿到咱们真正想拿到的位置:
select a(.|) from b;
# select a. (|) from b
复制代码
对比后发现,第一行拥有 4 个 Token,语法错误,而通过修改的第二行拥有 5 个 Token(一个光标补位),语法正确,且光标所在位置等价于第一行咱们但愿提示的位置,此问题得以解决。
咱们拥有了内置 “智能提示” 功能的语法解析器,定制了一套自定义的 SQL 词法、文法描述,便完成了 sql-lexer
与 sql-parser
这一层。因为 SQL 文法完善工做很是庞大,且须要持续推动,这里举流计算中,申明动态维表的例子:
CREATE TABLE dwd_log_pv_wl_ri(
PRIMARY KEY(rowkey),
PERIOD FOR SYSTEM_TIME
) WITH ()
复制代码
要支持这种语法,咱们在非终结符 tableOption
下增长两个分支便可:
const tableOption = () =>
chain([
chain(stringOrWord, dataType)(),
chain("primary", "key", "(", primaryKeyList, ")")(),
chain("period", "for", "system_time")()
])();
复制代码
sql-reader:
为了方便解析 SQL 语法树,咱们在 sql-reader
内置了几个经常使用方法,好比:
select a, b, | from d
会找到这个 selectStatement
。from
以后跟的语法,不但要考虑嵌套场景,别名,分组,方言,还要追溯每一个字段来源于哪张表(针对 join 或 union 的状况)。有了 sql-reader,咱们能够保证在这种层层嵌套 + 别名混淆 + select * 这种复杂的场景下,仍然能追溯到字段的最原始名称,最原始的表名:
这样上层业务拓展时,能够拿到足够准、足够多的信息,具备足够好的拓展型。
monaco-editor plugin:
咱们也支持了更上层的封装,Monaco Editor 插件级别的,只须要填一些参数:获取表名、获取字段的回调函数就能 Work,统一了内部业务的调用方式:
import { monacoSqlAutocomplete } from '@alife/monaco-sql-plugin';
// Get monaco and editor.
monacoSqlAutocomplete(monaco, editor, {
onInputTableField: async tableName => { // ...},
onInputTableName: async () => { // ... },
onInputFunctionName: async () => { // ... },
onHoverTableName: async cursorInfo => { // ... },
onHoverTableField: (fieldName, extra) => { // ... },
onHoverFunctionName: functionName => { // ... }
});
复制代码
好比实现了 onInputTableField
接口,咱们能够拿到当前表名信息,轻松实现字段提示:
你也许会看到,上图中鼠标位置有错误提示(红色波浪线),但依然给出了正确的推荐提示。这得益于咱们对 syntax-parser 内部机制的优化,将语法检查与智能提示分为两个模块独立处理,通过语法解析,虽然抛出了语法错误,但由于有了光标的加入,最终生成了语法树。
再好比实现了 onHoverFunctionName
,能够自定义鼠标 hover 在函数时的提示信息:
得益于 sql-reader
,咱们对 sql 语句作了层层解析,因此才能把自动提示作到极致。好比在作字段自动提示时,经历了以下判断步骤:
而你只须要实现 onInputTableField
,告诉程序每一个表能够提供哪些字段,整个流程就会严格的层层检查表名提供对原始字段与 selectList
描述的输出字段,找到映射关系并逐级传递、校验,最终 Merge 后一直冒泡到当前光标位置所在语句,造成输入建议。
整个智能提示的封装链条以下:
syntax-parser -> sql-parser -> monaco-editor-plugin
对应关系是:
语法解析器生成器 -> SQL 语法解析器 -> 编辑器插件
这样逻辑层次清晰,解耦,并且能够从任意节点切入,进行自定义,好比:
从 syntax-parser 开始使用
从最底层开始使用,也许有两个目的:
针对这种状况,首先将目标文法找到,转化成 syntax-parser 的语法,好比:
chain(word, "=>", word);
复制代码
再仿照 sql-parser -> monaco-editor-plugin 的结构把上层封装依次实现。
从 sql-parser 开始使用
也许你须要的仅仅是一颗 SQL 语法树?或者你的输出目标不是 SQL 编辑器而是一个 UI 界面?那能够试试直接使用 sql-parser。
sql-parser 不只能够生成语法树,还能找到当前光标位置所在语法树的节点,找到 SQL 某个语法返回的全部字段列表等功能,基于它,甚至能够作 UI 与 SQL 文本互转的应用。
从 monaco-editor-plugin 开始使用
也许你须要支持自动提示的 SQL 编辑器,那太棒了,直接用 monaco-editor-plugin 吧,根据你的业务场景或我的喜爱,实现一个定制的 monaco-editor 交互插件。
目前咱们只开源最底层的 syntax-parser,这也是业务无关的语法解析引擎生成器,期待您的使用与建议!
若是你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。