1. 引言

syntax-parser 是一个 JS 版语法解析器生成器,具有分词、语法树解析的能力。

通过两个例子介绍它的功能。

第一个例子是创建一个词法解析器 myLexer

  1. import { createLexer } from "syntax-parser";
  2. const myLexer = createLexer([
  3. {
  4. type: "whitespace",
  5. regexes: [/^(\s+)/],
  6. ignore: true
  7. },
  8. {
  9. type: "word",
  10. regexes: [/^([a-zA-Z0-9]+)/]
  11. },
  12. {
  13. type: "operator",
  14. regexes: [/^(\+)/]
  15. }
  16. ]);

如上,通过正则分别匹配了 “空格”、“字母或数字”、“加号”,并将匹配到的空格忽略(不输出)。

分词匹配是从左到右的,优先匹配数组的第一项,依此类推。

接下来使用 myLexer

  1. const tokens = myLexer("a + b");
  2. // tokens:
  3. // [
  4. // { "type": "word", "value": "a", "position": [0, 1] },
  5. // { "type": "operator", "value": "+", "position": [2, 3] },
  6. // { "type": "word", "value": "b", "position": [4, 5] },
  7. // ]

'a + b' 会按照上面定义的 “三种类型” 被分割为数组,数组的每一项都包含了原始值以及其位置。

第二个例子是创建一个语法解析器 myParser

  1. import { createParser, chain, matchTokenType, many } from "syntax-parser";
  2. const root = () => chain(addExpr)(ast => ast[0]);
  3. const addExpr = () =>
  4. chain(matchTokenType("word"), many(addPlus))(ast => ({
  5. left: ast[0].value,
  6. operator: ast[1] && ast[1][0].operator,
  7. right: ast[1] && ast[1][0].term
  8. }));
  9. const addPlus = () =>
  10. chain("+"), root)(ast => ({
  11. operator: ast[0].value,
  12. term: ast[1]
  13. }));
  14. const myParser = createParser(
  15. root, // Root grammar.
  16. myLexer // Created in lexer example.
  17. );

利用 chain 函数书写文法表达式:通过字面量的匹配(比如 + 号),以及 matchTokenType 来模糊匹配我们上面词法解析出的 “三种类型”,就形成了完整的文法表达式。

syntax-parser 还提供了其他几个有用的函数,比如 many optional 分别表示匹配多次和匹配零或一次。

接下来使用 myParser

  1. const ast = myParser("a + b");
  2. // ast:
  3. // [{
  4. // "left": "a",
  5. // "operator": "+",
  6. // "right": {
  7. // "left": "b",
  8. // "operator": null,
  9. // "right": null
  10. // }
  11. // }]

2. 精读

按照下面的思路大纲进行源码解读:

  • 词法解析

    • 词汇与概念
    • 分词器
  • 语法解析
    • 词汇与概念
    • 重新做一套 “JS 执行引擎”
    • 实现 Chain 函数
    • 引擎执行
    • 何时算执行完
    • “或” 逻辑的实现
    • many, optional, plus 的实现
    • 错误提示 & 输入推荐
    • First 集优化

词法解析

词法解析有点像 NLP 中分词,但比分词简单的时,词法解析的分词逻辑是明确的,一般用正则片段表达。

词汇与概念

  • Lexer:词法解析器。
  • Token:分词后的词素,包括 value:值position:位置type:类型

分词器

分词器 createLexer 函数接收的是一个正则数组,因此思路是遍历数组,一段一段匹配字符串。

我们需要这几个函数:

  1. class Tokenizer {
  2. public tokenize(input: string) {
  3. // 调用 getNextToken 对输入字符串 input 进行正则匹配,匹配完后 substring 裁剪掉刚才匹配的部分,再重新匹配直到字符串裁剪完
  4. }
  5. private getNextToken(input: string) {
  6. // 调用 getTokenOnFirstMatch 对输入字符串 input 进行遍历正则匹配,一旦有匹配到的结果立即返回
  7. }
  8. private getTokenOnFirstMatch({
  9. input,
  10. type,
  11. regex
  12. }: {
  13. input: string;
  14. type: string;
  15. regex: RegExp;
  16. }) {
  17. // 对输入字符串 input 进行正则 regex 的匹配,并返回 Token 对象的基本结构
  18. }
  19. }

tokenize 是入口函数,循环调用 getNextToken 匹配 Token 并裁剪字符串直到字符串被裁完。

语法解析

语法解析是基于词法解析的,输入是 Tokens,根据文法规则依次匹配 Token,当 Token 匹配完且完全符合文法规范后,语法树就出来了。

词法解析器生成器就是 “生成词法解析器的工具”,只要输入规定的文法描述,内部引擎会自动做掉其余的事。

这个生成器的难点在于,匹配 “或” 逻辑失败时,调用栈需要恢复到失败前的位置,而 JS 引擎中调用栈不受代码控制,因此代码需要在模拟引擎中执行。

词汇与概念

  • Parser:语法解析器。
  • ChainNode:连续匹配,执行链四节点之一。
  • TreeNode:匹配其一,执行链四节点之一。
  • FunctionNode:函数节点,执行链四节点之一。
  • MatchNode:匹配字面量或某一类型的 Token,执行链四节点之一。每一次正确的 Match 匹配都会消耗一个 Token。

重新做一套 “JS 执行引擎”

为什么要重新做一套 JS 执行引擎?看下面的代码:

  1. const main = () =>
  2. chain(functionA(), tree(functionB1(), functionB2()), functionC());
  3. const functionA = () => chain("a");
  4. const functionB1 = () => chain("b", "x");
  5. const functionB2 = () => chain("b", "y");
  6. const functionC = () => chain("c");

假设 chain('a') 可以匹配 Token a,而 chain(functionC)) 可以匹配到 Token c

当输入为 a b y c 时,我们该怎么写 tree 函数呢?

我们期望匹配到 functionB1 时失败,再尝试 functionB2,直到有一个成功为止。

那么 tree 函数可能是这样的:

  1. function tree(...funs) {
  2. // ... 存储当前 tokens
  3. for (const fun of funs) {
  4. // ... 复位当前 tokens
  5. const result = fun();
  6. if (result === true) {
  7. return result;
  8. }
  9. }
  10. }

不断尝试 tree 中内容,直到能正确匹配结果后返回这个结果。由于正确的匹配会消耗 Token,因此需要在执行前后存储当前 Tokens 内容,在执行失败时恢复 Token 并尝试新的执行链路。

这样看去很容易,不是吗?

然而,下面这个例子会打破这个美好的假设,让我们稍稍换几个值吧:

  1. const main = () =>
  2. chain(functionA(), tree(functionB1(), functionB2()), functionC());
  3. const functionA = () => chain("a");
  4. const functionB1 = () => chain("b", "y");
  5. const functionB2 = () => chain("b");
  6. const functionC = () => chain("y", "c");

输入仍然是 a b y c,看看会发生什么?

线路 functionA -> functionB1a 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 函数

用链表设计 Chain 函数是最佳的选择,我们要模拟 JS 调用栈了。

  1. const main = () => chain(functionA, [functionB1, functionB2], functionC)();
  2. const functionA = () => chain("a")();
  3. const functionB1 = () => chain("b", "y")();
  4. const functionB2 = () => chain("b")();
  5. const functionC = () => chain("y", "c")();

上面的例子只改动了一小点,那就是函数不会立即执行。

chain 将函数转化为 FunctionNode,将字面量 ab 转化为 MatchNode,将 [] 转化为 TreeNode,将自己转化为 ChainNode

我们就得到了如下的链表:

  1. ChainNode(main)
  2. └── FunctionNode(functionA) TreeNode FunctionNode(functionC)
  3. │── FunctionNode(functionB1)
  4. └── FunctionNode(functionB2)

至于为什么 FunctionNode 不直接展开成 MatchNode,请思考这样的描述:const list = () => chain(',', list)。直接展开则陷入递归死循环,实际上 Tokens 数量总有限,用到再展开总能匹配尽 Token,而不会无限展开下去。

那么需要一个函数,将 chain 函数接收的不同参数转化为对应 Node 节点:

  1. const createNodeByElement = (
  2. element: IElement,
  3. parentNode: ParentNode,
  4. parentIndex: number,
  5. parser: Parser
  6. ): Node => {
  7. if (element instanceof Array) {
  8. // ... return TreeNode
  9. } else if (typeof element === "string") {
  10. // ... return MatchNode
  11. } else if (typeof element === "boolean") {
  12. // ... true 表示一定匹配成功,false 表示一定匹配失败,均不消耗 Token
  13. } else if (typeof element === "function") {
  14. // ... return FunctionNode
  15. }
  16. };

createNodeByElement 函数源码

引擎执行

引擎执行其实就是访问链表,通过 visit 函数是最佳手段。

  1. const visit = tailCallOptimize(
  2. ({
  3. node,
  4. store,
  5. visiterOption,
  6. childIndex
  7. }: {
  8. node: Node;
  9. store: VisiterStore;
  10. visiterOption: VisiterOption;
  11. childIndex: number;
  12. }) => {
  13. if (node instanceof ChainNode) {
  14. // 调用 `visitChildNode` 访问子节点
  15. } else if (node instanceof TreeNode) {
  16. // 调用 `visitChildNode` 访问子节点
  17. visitChildNode({ node, store, visiterOption, childIndex });
  18. } else if (node instanceof MatchNode) {
  19. // 与当前 Token 进行匹配,匹配成功则调用 `visitNextNodeFromParent` 访问父级 Node 的下一个节点,匹配失败则调用 `tryChances`,这会在 “或” 逻辑里说明。
  20. } else if (node instanceof FunctionNode) {
  21. // 执行函数节点,并替换掉当前节点,重新 `visit` 一遍
  22. }
  23. }
  24. );

由于 visit 函数执行次数至多可能几百万次,因此使用 tailCallOptimize 进行尾递归优化,防止内存或堆栈溢出。

visit 函数只负责访问节点本身,而 visitChildNode 函数负责访问节点的子节点(如果有),而 visitNextNodeFromParent 函数负责在没有子节点时,找到父级节点的下一个子节点访问。

  1. function visitChildNode({
  2. node,
  3. store,
  4. visiterOption,
  5. childIndex
  6. }: {
  7. node: ParentNode;
  8. store: VisiterStore;
  9. visiterOption: VisiterOption;
  10. childIndex: number;
  11. }) {
  12. if (node instanceof ChainNode) {
  13. const child = node.childs[childIndex];
  14. if (child) {
  15. // 调用 `visit` 函数访问子节点 `child`
  16. } else {
  17. // 如果没有子节点,就调用 `visitNextNodeFromParent` 往上找了
  18. }
  19. } else {
  20. // 对于 TreeNode,如果不是访问到了最后一个节点,则添加一次 “存档”
  21. // 调用 `addChances`
  22. // 同时如果有子元素,`visit` 这个子元素
  23. }
  24. }
  25. const visitNextNodeFromParent = tailCallOptimize(
  26. (
  27. node: Node,
  28. store: VisiterStore,
  29. visiterOption: VisiterOption,
  30. astValue: any
  31. ) => {
  32. if (!node.parentNode) {
  33. // 找父节点的函数没有父级时,下面再介绍,记住这个位置叫 END 位。
  34. }
  35. if (node.parentNode instanceof ChainNode) {
  36. // A B <- next node C
  37. // └── node <- current node
  38. // 正如图所示,找到 nextNode 节点调用 `visit`
  39. } else if (node.parentNode instanceof TreeNode) {
  40. // TreeNode 节点直接利用 `visitNextNodeFromParent` 跳过。因为同一时间 TreeNode 节点只有一个分支生效,所以它没有子元素了
  41. }
  42. }
  43. );

可以看到 visitChildNodevisitNextNodeFromParent 函数都只处理好了自己的事情,而将其他工作交给别的函数完成,这样函数间职责分明,代码也更易懂。

有了 vist visitChildNodevisitNextNodeFromParent,就完成了节点的访问、子节点的访问、以及当没有子节点时,追溯到上层节点的访问。

visit 函数源码

何时算执行完

visitNextNodeFromParent 函数访问到 END 位 时,是时候做一个了结了:

  • 当 Tokens 正好消耗完,完美匹配成功。
  • Tokens 没消耗完,匹配失败。
  • 还有一种失败情况,是 Chance 用光时,结合下面的 “或” 逻辑一起说。

“或” 逻辑的实现

“或” 逻辑是重构 JS 引擎的原因,现在这个问题被很好解决掉了。

  1. const main = () => chain(functionA, [functionB1, functionB2], functionC)();

比如上面的代码,当遇到 [] 数组结构时,被认为是 “或” 逻辑,子元素存储在 TreeNode 节点中。

visitChildNode 函数中,与 ChainNode 不同之处在于,访问 TreeNode 子节点时,还会调用 addChances 方法,为下一个子元素存储执行状态,以便未来恢复到这个节点继续执行。

addChances 维护了一个池子,调用是先进后出:

  1. function addChances(/* ... */) {
  2. const chance = {
  3. node,
  4. tokenIndex,
  5. childIndex
  6. };
  7. store.restChances.push(chance);
  8. }

addChance 相对的就是 tryChance

下面两种情况会调用 tryChances

  • MatchNode 匹配失败。节点匹配失败是最常见的失败情况,但如果 chances 池还有存档,就可以恢复过去继续尝试。
  • 没有下一个节点了,但 Tokens 还没消耗完,也说明匹配失败了,此时调用 tryChances 继续尝试。

我们看看神奇的存档回复函数 tryChances 是如何做的:

  1. function tryChances(
  2. node: Node,
  3. store: VisiterStore,
  4. visiterOption: VisiterOption
  5. ) {
  6. if (store.restChances.length === 0) {
  7. // 直接失败
  8. }
  9. const nextChance = store.restChances.pop();
  10. // reset scanner index
  11. store.scanner.setIndex(nextChance.tokenIndex);
  12. visit({
  13. node: nextChance.node,
  14. store,
  15. visiterOption,
  16. childIndex: nextChance.childIndex
  17. });
  18. }

tryChances 其实很简单,除了没有 chances 就失败外,找到最近的一个 chance 节点,恢复 Token 指针位置并 visit 这个节点就等价于读档。

addChance 源码

tryChances 源码

many, optional, plus 的实现

这三个方法实现的也很精妙。

先看可选函数 optional:

  1. export const optional = (...elements: IElements) => {
  2. return chain([chain(...elements)(/**/)), true])(/**/);
  3. };

可以看到,可选参数实际上就是一个 TreeNode,也就是:

  1. chain(optional("a"))();
  2. // 等价于
  3. chain(["a", true])();

为什么呢?因为当 'a' 匹配失败后,true 是一个不消耗 Token 一定成功的匹配,整体来看就是 “可选” 的意思。

进一步解释下,如果 'a' 没有匹配上,则 true 一定能匹配上,匹配 true 等于什么都没匹配,就等同于这个表达式不存在。

再看匹配一或多个的函数 plus

  1. export const plus = (...elements: IElements) => {
  2. const plusFunction = () =>
  3. chain(chain(...elements)(/**/), optional(plusFunction))(/**/);
  4. return plusFunction;
  5. };

能看出来吗?plus 函数等价于一个新递归函数。也就是:

  1. const aPlus = () => chain(plus("a"))();
  2. // 等价于
  3. const aPlus = () => chain(plusFunc)();
  4. const plusFunc = () => chain("a", optional(plusFunc))();

通过不断递归自身的方式匹配到尽可能多的元素,而每一层的 optional 保证了任意一层匹配失败后可以及时跳到下一个文法,不会失败。

最后看匹配多个的函数 many

  1. export const many = (...elements: IElements) => {
  2. return optional(plus(...elements));
  3. };

many 就是 optionalplus,不是吗?

这三个神奇的函数都利用了已有功能实现,建议每个函数留一分钟左右时间思考为什么。

optional plus many 函数源码

错误提示 & 输入推荐

错误提示与输入推荐类似,都是给出错误位置或光标位置后期待的输入。

输入推荐,就是给定字符串与光标位置,给出光标后期待内容的功能。

首先通过光标位置找到光标的 上一个 Token,再通过 findNextMatchNodes 找到这个 Token 后所有可能匹配到的 MatchNode,这就是推荐结果。

那么如何实现 findNextMatchNodes 呢?看下面:

  1. function findNextMatchNodes(node: Node, parser: Parser): MatchNode[] {
  2. const nextMatchNodes: MatchNode[] = [];
  3. let passCurrentNode = false;
  4. const visiterOption: VisiterOption = {
  5. onMatchNode: (matchNode, store, currentVisiterOption) => {
  6. if (matchNode === node && passCurrentNode === false) {
  7. passCurrentNode = true;
  8. // 调用 visitNextNodeFromParent,忽略自身
  9. } else {
  10. // 遍历到的 MatchNode
  11. nextMatchNodes.push(matchNode);
  12. }
  13. // 这个是画龙点睛的一笔,所有推荐都当作匹配失败,通过 tryChances 可以找到所有可能的 MatchNode
  14. tryChances(matchNode, store, currentVisiterOption);
  15. }
  16. };
  17. newVisit({ node, scanner: new Scanner([]), visiterOption, parser });
  18. return nextMatchNodes;
  19. }

所谓找到后续节点,就是通过 Visit 找到所有的 MatchNode,而 MatchNode 只要匹配一次即可,因为我们只要找到第一层级的 MatchNode

通过每次匹配后执行 tryChances,就可以找到所有 MatchNode 节点了!

再看错误提示,我们要记录最后出错的位置,再采用输入推荐即可。

但光标所在的位置是期望输入点,这个输入点也应该参与语法树的生成,而错误提示不包含光标,所以我们要 执行两次 visit

举个例子:

  1. select | from b;

| 是光标位置,此时语句内容是 select from b; 显然是错误的,但光标位置应该给出提示,给出提示就需要正确解析语法树,所以对于提示功能,我们需要将光标位置考虑进去一起解析。因此一共有两次解析。

findNextMatchNodes 函数源码

First 集优化

构建 First 集是个自下而上的过程,当访问到 MatchNode 节点时,其值就是其父节点的一个 First 值,当父节点的 First 集收集完毕后,,就会触发它的父节点 First 集收集判断,如此递归,最后完成 First 集收集的是最顶级节点。

篇幅原因,不再赘述,可以看 这张图

generateFirstSet 函数源码

3. 总结

这篇文章是对 《手写 SQL 编译器》 系列的总结,从源码角度的总结!

该系列的每篇文章都以图文的方式介绍了各技术细节,可以作为补充阅读:

讨论地址是:精读《syntax-parser 源码》 · Issue #133 · dt-fe/weekly

如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

精读《syntax-parser 源码》的更多相关文章

  1. 精读《V8 引擎 Lazy Parsing》

    1. 引言 本周精读的文章是 V8 引擎 Lazy Parsing,看看 V8 引擎为了优化性能,做了怎样的尝试吧! 这篇文章介绍的优化技术叫 preparser,是通过跳过不必要函数编译的方式优化性 ...

  2. 深入浏览器工作原理和JS引擎(V8引擎为例)

    浏览器工作原理和JS引擎 1.浏览器工作原理 在浏览器中输入查找内容,浏览器是怎样将页面加载出来的?以及JavaScript代码在浏览器中是如何被执行的? 大概流程可观察以下图: 首先,用户在浏览器搜 ...

  3. [翻译] V8引擎的解析

    原文:Parsing in V8 explained 本文档介绍了 V8 引擎是如何解析 JavaScript 源代码的,以及我们将改进它的计划. 动机 我们有个解析器和一个更快的预解析器(~2x), ...

  4. 一文搞懂V8引擎的垃圾回收

    引言 作为目前最流行的JavaScript引擎,V8引擎从出现的那一刻起便广泛受到人们的关注,我们知道,JavaScript可以高效地运行在浏览器和Nodejs这两大宿主环境中,也是因为背后有强大的V ...

  5. Chrome V8引擎系列随笔 (1):Math.Random()函数概览

    先让大家来看一幅图,这幅图是V8引擎4.7版本和4.9版本Math.Random()函数的值的分布图,我可以这么理解 .从下图中,也许你会认为这是个二维码?其实这幅图告诉我们一个道理,第二张图的点的分 ...

  6. (译)V8引擎介绍

    V8是什么? V8是谷歌在德国研发中心开发的一个JavaScript引擎.开源并且用C++实现.可以用于运行于客户端和服务端的Javascript程序. V8设计的初衷是为了提高浏览器上JavaScr ...

  7. 浅谈Chrome V8引擎中的垃圾回收机制

    垃圾回收器 JavaScript的垃圾回收器 JavaScript使用垃圾回收机制来自动管理内存.垃圾回收是一把双刃剑,其好处是可以大幅简化程序的内存管理代码,降低程序员的负担,减少因 长时间运转而带 ...

  8. V8引擎嵌入指南

    如果已读过V8编程入门那你已经熟悉了如句柄(handle).作用域(scope)和上下文(context)之类的关键概念,以及如何将V8引擎作为一个独立的虚拟机来使用.本文将进一步讨论这些概念,并介绍 ...

  9. 浅谈V8引擎中的垃圾回收机制

    最近在看<深入浅出nodejs>关于V8垃圾回收机制的章节,转自:http://blog.segmentfault.com/skyinlayer/1190000000440270 这篇文章 ...

  10. 深入出不来nodejs源码-V8引擎初探

    原本打算是把node源码看得差不多了再去深入V8的,但是这两者基本上没办法分开讲. 与express是基于node的封装不同,node是基于V8的一个应用,源码内容已经渗透到V8层面,因此这章简述一下 ...

随机推荐

  1. 纯 CSS 绘制三角形(各种角度)

     转载:https://www.cnblogs.com/lhb25/p/css-and-css3-triangle.html       Triangle Up #triangle-up { widt ...

  2. leetcode 902 数位dp 不包含0

    复习了一下数位dp 肯定不包含0,但是通常数位dp最后计算的结果较小的是包含前导0的,只是没显示出来而已,所以这题需要前导0,但是非前导0是不需要算进去的,因此,加个是否是前导0的状态即可 class ...

  3. USACO 邮票 Stamps

    f[x]表示组成 x 最少需要的邮票数量 一一举例 最多贴5张邮票,有三种邮票可用,分别是1分,3分,8分 组成0分需要0张邮票 ——f[0]=0 组成1分需要在0分的基础上加上一张1分邮票 ——f[ ...

  4. 用idea 创建一个spring小demo,基于xml文件配置

    1.首先,File->new->project ,进入新增项目页面 或者在 2.勾选spring,然后点击下一步 3.修改项目名称和项目位置 进入页面后 5.创建一个spring配置文件 ...

  5. 一步一步 copy163: 网易严选 ---- vue-cli

    面试点 组件间通信 生命周期函数 路由 - 参数 - 重定向 vuex 参考 网易严选商城小程序全栈开发,域名备案中近期上线(mpvue+koa2+mysql) 小程序服务端源码地址 小程序源码地址 ...

  6. python从入门到实践-4章操作列表

    magicians = ['alice','david','carolina']for magician in magicians: print(magician) print(magician.ti ...

  7. 大数相加 Big Num

    代码: #include<stdio.h>#include<algorithm>#include<iostream>#include<string.h> ...

  8. Java作业五(2017-10-15)

    /*3-6.程序员;龚猛*/ 1 package zhenshu; import java.util.Scanner; public class text { public static void m ...

  9. js 中 的 BOM对象

    BOM对象(浏览器对象模型 Browser Object Model) 01.页面的前进和后退 02.移动,调整和关闭浏览器窗口 03.创建新的浏览器窗口 01.window对象 ***** 核心对象 ...

  10. monaco editor + vue的配置

    monaco editor是vscode的御用编辑器. 功能非常强大,使用方便轻巧,对js\ts等等语言支持都良好,能方便的扩展以支持其他语言或者自定义的特性. 夸了这么多,这里只说它一个问题: 这货 ...