syntax-parser 是一个 JS 版语法解析器生成器,具备分词、语法树解析的能力。前端
经过两个例子介绍它的功能。node
第一个例子是建立一个词法解析器 myLexer
:git
import { createLexer } from "syntax-parser"; const myLexer = createLexer([ { type: "whitespace", regexes: [/^(\s+)/], ignore: true }, { type: "word", regexes: [/^([a-zA-Z0-9]+)/] }, { type: "operator", regexes: [/^(\+)/] } ]);
如上,经过正则分别匹配了 “空格”、“字母或数字”、“加号”,并将匹配到的空格忽略(不输出)。github
分词匹配是从左到右的,优先匹配数组的第一项,依此类推。
接下来使用 myLexer
:sql
const tokens = myLexer("a + b"); // tokens: // [ // { "type": "word", "value": "a", "position": [0, 1] }, // { "type": "operator", "value": "+", "position": [2, 3] }, // { "type": "word", "value": "b", "position": [4, 5] }, // ]
'a + b'
会按照上面定义的 “三种类型” 被分割为数组,数组的每一项都包含了原始值以及其位置。typescript
第二个例子是建立一个语法解析器 myParser
:数组
import { createParser, chain, matchTokenType, many } from "syntax-parser"; const root = () => chain(addExpr)(ast => ast[0]); const addExpr = () => chain(matchTokenType("word"), many(addPlus))(ast => ({ left: ast[0].value, operator: ast[1] && ast[1][0].operator, right: ast[1] && ast[1][0].term })); const addPlus = () => chain("+"), root)(ast => ({ operator: ast[0].value, term: ast[1] })); const myParser = createParser( root, // Root grammar. myLexer // Created in lexer example. );
利用 chain
函数书写文法表达式:经过字面量的匹配(好比 +
号),以及 matchTokenType
来模糊匹配咱们上面词法解析出的 “三种类型”,就造成了完整的文法表达式。缓存
syntax-parser
还提供了其余几个有用的函数,好比 many
optional
分别表示匹配屡次和匹配零或一次。性能优化
接下来使用 myParser
:函数
const ast = myParser("a + b"); // ast: // [{ // "left": "a", // "operator": "+", // "right": { // "left": "b", // "operator": null, // "right": null // } // }]
按照下面的思路大纲进行源码解读:
词法解析
语法解析
词法解析有点像 NLP 中分词,但比分词简单的时,词法解析的分词逻辑是明确的,通常用正则片断表达。
value:值
、position:位置
、type:类型
。分词器 createLexer
函数接收的是一个正则数组,所以思路是遍历数组,一段一段匹配字符串。
咱们须要这几个函数:
class Tokenizer { public tokenize(input: string) { // 调用 getNextToken 对输入字符串 input 进行正则匹配,匹配完后 substring 裁剪掉刚才匹配的部分,再从新匹配直到字符串裁剪完 } private getNextToken(input: string) { // 调用 getTokenOnFirstMatch 对输入字符串 input 进行遍历正则匹配,一旦有匹配到的结果当即返回 } private getTokenOnFirstMatch({ input, type, regex }: { input: string; type: string; regex: RegExp; }) { // 对输入字符串 input 进行正则 regex 的匹配,并返回 Token 对象的基本结构 } }
tokenize
是入口函数,循环调用 getNextToken
匹配 Token 并裁剪字符串直到字符串被裁完。
语法解析是基于词法解析的,输入是 Tokens,根据文法规则依次匹配 Token,当 Token 匹配完且彻底符合文法规范后,语法树就出来了。
词法解析器生成器就是 “生成词法解析器的工具”,只要输入规定的文法描述,内部引擎会自动作掉其他的事。
这个生成器的难点在于,匹配 “或” 逻辑失败时,调用栈须要恢复到失败前的位置,而 JS 引擎中调用栈不受代码控制,所以代码须要在模拟引擎中执行。
为何要从新作一套 JS 执行引擎?看下面的代码:
const main = () => chain(functionA(), tree(functionB1(), functionB2()), functionC()); const functionA = () => chain("a"); const functionB1 = () => chain("b", "x"); const functionB2 = () => chain("b", "y"); const functionC = () => chain("c");
假设 chain('a')
能够匹配 Token a
,而 chain(functionC))
能够匹配到 Token c
。
当输入为 a b y c
时,咱们该怎么写 tree
函数呢?
咱们指望匹配到 functionB1
时失败,再尝试 functionB2
,直到有一个成功为止。
那么 tree
函数多是这样的:
function tree(...funs) { // ... 存储当前 tokens for (const fun of funs) { // ... 复位当前 tokens const result = fun(); if (result === true) { return result; } } }
不断尝试 tree
中内容,直到能正确匹配结果后返回这个结果。因为正确的匹配会消耗 Token,所以须要在执行先后存储当前 Tokens 内容,在执行失败时恢复 Token 并尝试新的执行链路。
这样看去很容易,不是吗?
然而,下面这个例子会打破这个美好的假设,让咱们稍稍换几个值吧:
const main = () => chain(functionA(), tree(functionB1(), functionB2()), functionC()); const functionA = () => chain("a"); const functionB1 = () => chain("b", "y"); const functionB2 = () => chain("b"); const functionC = () => chain("y", "c");
输入仍然是 a b y c
,看看会发生什么?
线路 functionA -> functionB1
是 a b y
很显然匹配会经过,但连上 functionC
后结果就是 a b y y c
,显然不符合输入。
此时正确的线路应该是 functionA -> functionB2 -> functionC
,结果才是 a b y c
!
咱们看 functionA -> functionB1 -> functionC
链路,当执行到 functionC
时才发现匹配错了,此时想要回到 functionB2
门也没有!由于 tree(functionB1(), functionB2())
的执行堆栈已退出,再也找不回来了。
因此须要模拟一个执行引擎,在遇到分叉路口时,将 functionB2
保存下来,随时能够回到这个节点从新执行。
用链表设计 Chain
函数是最佳的选择,咱们要模拟 JS 调用栈了。
const main = () => chain(functionA, [functionB1, functionB2], functionC)(); const functionA = () => chain("a")(); const functionB1 = () => chain("b", "y")(); const functionB2 = () => chain("b")(); const functionC = () => chain("y", "c")();
上面的例子只改动了一小点,那就是函数不会当即执行。
chain
将函数转化为 FunctionNode
,将字面量 a
或 b
转化为 MatchNode
,将 []
转化为 TreeNode
,将本身转化为 ChainNode
。
咱们就获得了以下的链表:
ChainNode(main) └── FunctionNode(functionA) ─ TreeNode ─ FunctionNode(functionC) │── FunctionNode(functionB1) └── FunctionNode(functionB2)
至于为何FunctionNode
不直接展开成MatchNode
,请思考这样的描述:const list = () => chain(',', list)
。直接展开则陷入递归死循环,实际上 Tokens 数量总有限,用到再展开总能匹配尽 Token,而不会无限展开下去。
那么须要一个函数,将 chain
函数接收的不一样参数转化为对应 Node 节点:
const createNodeByElement = ( element: IElement, parentNode: ParentNode, parentIndex: number, parser: Parser ): Node => { if (element instanceof Array) { // ... return TreeNode } else if (typeof element === "string") { // ... return MatchNode } else if (typeof element === "boolean") { // ... true 表示必定匹配成功,false 表示必定匹配失败,均不消耗 Token } else if (typeof element === "function") { // ... return FunctionNode } };
引擎执行其实就是访问链表,经过 visit
函数是最佳手段。
const visit = tailCallOptimize( ({ node, store, visiterOption, childIndex }: { node: Node; store: VisiterStore; visiterOption: VisiterOption; childIndex: number; }) => { if (node instanceof ChainNode) { // 调用 `visitChildNode` 访问子节点 } else if (node instanceof TreeNode) { // 调用 `visitChildNode` 访问子节点 visitChildNode({ node, store, visiterOption, childIndex }); } else if (node instanceof MatchNode) { // 与当前 Token 进行匹配,匹配成功则调用 `visitNextNodeFromParent` 访问父级 Node 的下一个节点,匹配失败则调用 `tryChances`,这会在 “或” 逻辑里说明。 } else if (node instanceof FunctionNode) { // 执行函数节点,并替换掉当前节点,从新 `visit` 一遍 } } );
因为visit
函数执行次数至多可能几百万次,所以使用tailCallOptimize
进行尾递归优化,防止内存或堆栈溢出。
visit
函数只负责访问节点自己,而 visitChildNode
函数负责访问节点的子节点(若是有),而 visitNextNodeFromParent
函数负责在没有子节点时,找到父级节点的下一个子节点访问。
function visitChildNode({ node, store, visiterOption, childIndex }: { node: ParentNode; store: VisiterStore; visiterOption: VisiterOption; childIndex: number; }) { if (node instanceof ChainNode) { const child = node.childs[childIndex]; if (child) { // 调用 `visit` 函数访问子节点 `child` } else { // 若是没有子节点,就调用 `visitNextNodeFromParent` 往上找了 } } else { // 对于 TreeNode,若是不是访问到了最后一个节点,则添加一次 “存档” // 调用 `addChances` // 同时若是有子元素,`visit` 这个子元素 } } const visitNextNodeFromParent = tailCallOptimize( ( node: Node, store: VisiterStore, visiterOption: VisiterOption, astValue: any ) => { if (!node.parentNode) { // 找父节点的函数没有父级时,下面再介绍,记住这个位置叫 END 位。 } if (node.parentNode instanceof ChainNode) { // A B <- next node C // └── node <- current node // 正如图所示,找到 nextNode 节点调用 `visit` } else if (node.parentNode instanceof TreeNode) { // TreeNode 节点直接利用 `visitNextNodeFromParent` 跳过。由于同一时间 TreeNode 节点只有一个分支生效,因此它没有子元素了 } } );
能够看到 visitChildNode
与 visitNextNodeFromParent
函数都只处理好了本身的事情,而将其余工做交给别的函数完成,这样函数间职责分明,代码也更易懂。
有了 vist
visitChildNode
与 visitNextNodeFromParent
,就完成了节点的访问、子节点的访问、以及当没有子节点时,追溯到上层节点的访问。
当 visitNextNodeFromParent
函数访问到 END 位
时,是时候作一个告终了:
Chance
用光时,结合下面的 “或” 逻辑一块儿说。“或” 逻辑是重构 JS 引擎的缘由,如今这个问题被很好解决掉了。
const main = () => chain(functionA, [functionB1, functionB2], functionC)();
好比上面的代码,当遇到 []
数组结构时,被认为是 “或” 逻辑,子元素存储在 TreeNode
节点中。
在 visitChildNode
函数中,与 ChainNode
不一样之处在于,访问 TreeNode
子节点时,还会调用 addChances
方法,为下一个子元素存储执行状态,以便将来恢复到这个节点继续执行。
addChances
维护了一个池子,调用是先进后出:
function addChances(/* ... */) { const chance = { node, tokenIndex, childIndex }; store.restChances.push(chance); }
与 addChance
相对的就是 tryChance
。
下面两种状况会调用 tryChances
:
MatchNode
匹配失败。节点匹配失败是最多见的失败状况,但若是 chances
池还有存档,就能够恢复过去继续尝试。tryChances
继续尝试。咱们看看神奇的存档回复函数 tryChances
是如何作的:
function tryChances( node: Node, store: VisiterStore, visiterOption: VisiterOption ) { if (store.restChances.length === 0) { // 直接失败 } const nextChance = store.restChances.pop(); // reset scanner index store.scanner.setIndex(nextChance.tokenIndex); visit({ node: nextChance.node, store, visiterOption, childIndex: nextChance.childIndex }); }
tryChances
其实很简单,除了没有 chances
就失败外,找到最近的一个 chance
节点,恢复 Token 指针位置并 visit
这个节点就等价于读档。
这三个方法实现的也很精妙。
先看可选函数 optional
:
export const optional = (...elements: IElements) => { return chain([chain(...elements)(/**/)), true])(/**/); };
能够看到,可选参数实际上就是一个 TreeNode
,也就是:
chain(optional("a"))(); // 等价于 chain(["a", true])();
为何呢?由于当 'a'
匹配失败后,true
是一个不消耗 Token 必定成功的匹配,总体来看就是 “可选” 的意思。
进一步解释下,若是'a'
没有匹配上,则true
必定能匹配上,匹配true
等于什么都没匹配,就等同于这个表达式不存在。
再看匹配一或多个的函数 plus
:
export const plus = (...elements: IElements) => { const plusFunction = () => chain(chain(...elements)(/**/), optional(plusFunction))(/**/); return plusFunction; };
能看出来吗?plus
函数等价于一个新递归函数。也就是:
const aPlus = () => chain(plus("a"))(); // 等价于 const aPlus = () => chain(plusFunc)(); const plusFunc = () => chain("a", optional(plusFunc))();
经过不断递归自身的方式匹配到尽量多的元素,而每一层的 optional
保证了任意一层匹配失败后能够及时跳到下一个文法,不会失败。
最后看匹配多个的函数 many
:
export const many = (...elements: IElements) => { return optional(plus(...elements)); };
many
就是 optional
的 plus
,不是吗?
这三个神奇的函数都利用了已有功能实现,建议每一个函数留一分钟左右时间思考为何。
错误提示与输入推荐相似,都是给出错误位置或光标位置后期待的输入。
输入推荐,就是给定字符串与光标位置,给出光标后期待内容的功能。
首先经过光标位置找到光标的 上一个 Token
,再经过 findNextMatchNodes
找到这个 Token
后全部可能匹配到的 MatchNode
,这就是推荐结果。
那么如何实现 findNextMatchNodes
呢?看下面:
function findNextMatchNodes(node: Node, parser: Parser): MatchNode[] { const nextMatchNodes: MatchNode[] = []; let passCurrentNode = false; const visiterOption: VisiterOption = { onMatchNode: (matchNode, store, currentVisiterOption) => { if (matchNode === node && passCurrentNode === false) { passCurrentNode = true; // 调用 visitNextNodeFromParent,忽略自身 } else { // 遍历到的 MatchNode nextMatchNodes.push(matchNode); } // 这个是画龙点睛的一笔,全部推荐都看成匹配失败,经过 tryChances 能够找到全部可能的 MatchNode tryChances(matchNode, store, currentVisiterOption); } }; newVisit({ node, scanner: new Scanner([]), visiterOption, parser }); return nextMatchNodes; }
所谓找到后续节点,就是经过 Visit
找到全部的 MatchNode
,而 MatchNode
只要匹配一次便可,由于咱们只要找到第一层级的 MatchNode
。
经过每次匹配后执行 tryChances
,就能够找到全部 MatchNode
节点了!
再看错误提示,咱们要记录最后出错的位置,再采用输入推荐便可。
但光标所在的位置是指望输入点,这个输入点也应该参与语法树的生成,而错误提示不包含光标,因此咱们要 执行两次 visit
。
举个例子:
select | from b;
|
是光标位置,此时语句内容是 select from b;
显然是错误的,但光标位置应该给出提示,给出提示就须要正确解析语法树,因此对于提示功能,咱们须要将光标位置考虑进去一块儿解析。所以一共有两次解析。
构建 First 集是个自下而上的过程,当访问到 MatchNode
节点时,其值就是其父节点的一个 First 值,当父节点的 First 集收集完毕后,,就会触发它的父节点 First 集收集判断,如此递归,最后完成 First 集收集的是最顶级节点。
篇幅缘由,再也不赘述,能够看 这张图。
这篇文章是对 《手写 SQL 编译器》 系列的总结,从源码角度的总结!
该系列的每篇文章都以图文的方式介绍了各技术细节,能够做为补充阅读:
讨论地址是: 精读《syntax-parser 源码》 · Issue #133 · dt-fe/weekly
若是你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。