syntax-parser 是一個 JS 版語法解析器生成器,具備分詞、語法樹解析的能力。前端
經過兩個例子介紹它的功能。node
第一個例子是建立一個詞法解析器 myLexer
:git
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
接下來使用 myLexer
:typescript
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
// }
// }]
複製代碼
按照下面的思路大綱進行源碼解讀:
詞法解析有點像 NLP 中分詞,但比分詞簡單的時,詞法解析的分詞邏輯是明確的,通常用正則片斷表達。
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 引擎中調用棧不受代碼控制,所以代碼須要在模擬引擎中執行。
爲何要從新作一套 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
函數是最佳的選擇,咱們要模擬 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 位
時,是時候作一個告終了:
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
池還有存檔,就能夠恢復過去繼續嘗試。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
這個節點就等價於讀檔。
這三個方法實現的也很精妙。
先看可選函數 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 集是個自下而上的過程,當訪問到 MatchNode
節點時,其值就是其父節點的一個 First 值,當父節點的 First 集收集完畢後,,就會觸發它的父節點 First 集收集判斷,如此遞歸,最後完成 First 集收集的是最頂級節點。
篇幅緣由,再也不贅述,能夠看 這張圖。
這篇文章是對 《手寫 SQL 編譯器》 系列的總結,從源碼角度的總結!
該系列的每篇文章都以圖文的方式介紹了各技術細節,能夠做爲補充閱讀:
若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。