精讀《syntax-parser 原始碼》

黃子毅發表於2019-03-04

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

如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

相關文章