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 -> 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 函式
用連結串列設計 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 位
時,是時候做一個了結了:
- 當 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
這個節點就等價於讀檔。
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
就是 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 集優化
構建 First 集是個自下而上的過程,當訪問到 MatchNode
節點時,其值就是其父節點的一個 First 值,當父節點的 First 集收集完畢後,,就會觸發它的父節點 First 集收集判斷,如此遞迴,最後完成 First 集收集的是最頂級節點。
篇幅原因,不再贅述,可以看 這張圖。
3. 總結
這篇文章是對 《手寫 SQL 編譯器》 系列的總結,從原始碼角度的總結!
該系列的每篇文章都以圖文的方式介紹了各技術細節,可以作為補充閱讀:
- 精讀《手寫 SQL 編譯器 - 詞法分析》
- 精讀《手寫 SQL 編譯器 - 文法介紹》
- 精讀《手寫 SQL 編譯器 - 語法分析》
- 精讀《手寫 SQL 編譯器 - 回溯》
- 精讀《手寫 SQL 編譯器 - 語法樹》
- 精讀《手寫 SQL 編譯器 - 錯誤提示》
- 精讀《手寫 SQL 編譯器 - 效能優化之快取》
- 精讀《手寫 SQL 編譯器 - 智慧提示》
如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。