精读《syntax-parser 源码》

1. 弁言

syntax-parser 是一个 JS 版语法剖析器天生器,具有分词、语法树剖析的才能。

经由历程两个例子引见它的功用。

第一个例子是建立一个词法剖析器 myLexer

import { createLexer } from "syntax-parser";

const myLexer = createLexer([
  {
    type: "whitespace",
    regexes: [/^(\s+)/],
    ignore: true
  },
  {
    type: "word",
    regexes: [/^([a-zA-Z0-9]+)/]
  },
  {
    type: "operator",
    regexes: [/^(\+)/]
  }
]);

如上,经由历程正则离别婚配了 “空格”、“字母或数字”、“加号”,并将婚配到的空格疏忽(不输出)。

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

接下来运用 myLexer

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' 会依据上面定义的 “三种范例” 被分割为数组,数组的每一项都包括了原始值以及其位置。

第二个例子是建立一个语法剖析器 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
//   }
// }]

2. 精读

依据下面的思绪纲要举行源码解读:

  • 词法剖析

    • 辞汇与观点
    • 分词器
  • 语法剖析

    • 辞汇与观点
    • 从新做一套 “JS 实行引擎”
    • 完成 Chain 函数
    • 引擎实行
    • 什么时刻算实行完
    • “或” 逻辑的完成
    • many, optional, plus 的完成
    • 毛病提醒 & 输入引荐
    • First 集优化

词法剖析

词法剖析有点像 NLP 中分词,但比分词简朴的时,词法剖析的分词逻辑是明白的,平常用正则片断表达。

辞汇与观点

  • Lexer:词法剖析器。
  • Token:分词后的词素,包括 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 引擎中挪用栈不受代码掌握,因而代码须要在模仿引擎中实行。

辞汇与观点

  • Parser:语法剖析器。
  • ChainNode:一连婚配,实行链四节点之一。
  • TreeNode:婚配其一,实行链四节点之一。
  • FunctionNode:函数节点,实行链四节点之一。
  • MatchNode:婚配字面量或某一范例的 Token,实行链四节点之一。每一次准确的 Match 婚配都邑斲丧一个 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 -> 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 挪用栈了。

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,将字面量 ab 转化为 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
  }
};

createNodeByElement 函数源码

引擎实行

引擎实行实在就是接见链表,经由历程 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 节点只要一个分支见效,所以它没有子元素了
    }
  }
);

能够看到 visitChildNodevisitNextNodeFromParent 函数都只处置惩罚好了本身的事变,而将其他事情交给别的函数完成,如许函数间职责清楚,代码也更易懂。

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

visit 函数源码

什么时刻算实行完

visitNextNodeFromParent 函数接见到 END 位 时,是时刻做一个了结了:

  • 当 Tokens 恰好斲丧完,圆满婚配胜利。
  • Tokens 没斲丧完,婚配失利。
  • 另有一种失利状况,是 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 池另有存档,就能够恢复过去继承尝试。
  • 没有下一个节点了,但 Tokens 还没斲丧完,也申明婚配失利了,此时挪用 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 这个节点就等价于读档。

addChance 源码

tryChances 源码

many, optional, plus 的完成

这三个要领完成的也很精巧。

先看可选函数 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 就是 optionalplus,不是吗?

这三个奇异的函数都应用了已有功用完成,发起每一个函数留一分钟摆布时候思索为何。

optional plus many 函数源码

毛病提醒 & 输入引荐

毛病提醒与输入引荐相似,都是给出毛病位置或光标位置后期待的输入。

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

起首经由历程光标位置找到光标的 上一个 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; 明显是毛病的,但光标位置应当给出提醒,给出提醒就须要准确剖析语法树,所以关于提醒功用,我们须要将光标位置斟酌进去一同剖析。因而一共有两次剖析。

findNextMatchNodes 函数源码

First 集优化

构建 First 集是个自下而上的历程,当接见到 MatchNode 节点时,其值就是其父节点的一个 First 值,当父节点的 First 集网络终了后,,就会触发它的父节点 First 集网络推断,云云递归,末了完成 First 集网络的是最顶级节点。

篇幅缘由,不再赘述,能够看 这张图

generateFirstSet 函数源码

3. 总结

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

该系列的每篇文章都以图文的体式格局引见了各技术细节,能够作为补充浏览:

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

假如你想介入议论,请点击这里,每周都有新的主题,周末或周一宣布。前端精读 – 帮你挑选靠谱的内容。

    原文作者:黄子毅
    原文地址: https://segmentfault.com/a/1190000018375201
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞