精讀《syntax-parser 源碼》

1. 引言

syntax-parser 是一個 JS 版語法解析器生成器,具備分詞、語法樹解析的能力。前端

經過兩個例子介紹它的功能。node

第一個例子是建立一個詞法解析器 myLexergit

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

分詞匹配是從左到右的,優先匹配數組的第一項,依此類推。sql

接下來使用 myLexertypescript

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 分別表示匹配屢次和匹配零或一次。bash

接下來使用 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

若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

相關文章
相關標籤/搜索