做者簡介 zqlu 螞蟻金服·數據體驗技術團隊前端
在前端開發中,一般提到語法解析等功能,這是都是有後端負責提供接口,前端調用。那麼前端是否能自主完成語法解析相關的功能,並在瀏覽器中運行呢?答案是確定,本文將描述一種簡化的語言稱爲Expr
語言,並在瀏覽器中完成對輸入的Expr
代碼作錯誤驗證、執行和翻譯等等功能。git
首先,介紹下咱們的Expr
語言,Expr
語言是本文假設的一種簡化的類C的語言,代碼片斷以下:github
a = 2;
b = 3
c = a * (b + 2);
d = a / (c - 1);
a; // 應該輸出2
b; // 應該輸出3
c;
d;
複製代碼
前4行的行爲是你們熟悉的賦值表達式,最後4行爲打印語句,即依次打印各個變量的值,固然這裏也包含了咱們熟悉的註釋。typescript
問題來了,咱們能在前端自主解析Expr語法的代碼,並解釋執它們嗎? 若是代碼片斷中有錯誤,咱們能給出準確的錯誤提示信息嗎?還有更多有趣的問題,咱們的Expr語言採用了中綴表達式,咱們能把輸入代碼翻譯成前綴表達式代碼嗎?咱們能對輸入的代碼片斷作代碼格式化嗎?咱們能把Expr代碼翻譯成其餘語言的源碼嗎?npm
首先看看最後的Demo,咱們能夠在Demo頁面代碼框輸入Expr語言的代碼,點擊執行按鈕,便可以看到執行後的結果:json
這些功能是在瀏覽器利用ANTLR v4來完成語法解析,並最終實現了語法驗證、解釋執行等功能。後端
Antlr4是ANother Tool for Language Recognition即另外一個語言識別工具,官方介紹爲Antlr4是一款強大的解析器生成工具,可用來讀取、處理、執行和翻譯結構化文本或二進制文件。瀏覽器
Antlr4生成的解析器包含了詞法分析程序和語法分析程序,沒錯,這就是編譯原理課程中的詞法分析和語法分析。寫了幾年前端是否是都忘記了,咱們只須要知道詞法分析程序是將輸入的代碼字符序列轉換成標記(Token)序列的程序,而語法分析程序則是將標記序列轉換成語法樹的程序。好在按照Antlr4規範制定了語法定義,Antlr4就能夠爲咱們生成解析器源碼,它不只能夠生成Java源碼,還能夠生成咱們前端方便的JavaScript和TypeScript源碼。不錯,在本文,咱們就是要用Antlr4生成的TypeScript版的解析器,來解析咱們的Expr語言代碼。bash
關於Antlr4的安裝和使用,你們能夠參照Github上的Getting Started with ANTLR v4,這裏不做介紹。簡單來講,使用 ANTLR v4,通常分爲三步:前端工程師
按照上述的介紹,爲了實現解釋執行咱們的Expr語言,首先第一步須要按照 ANTLR v4 的規範來定義Expr語言的語法定義文件Expr.g4。這裏簡單介紹下ANTLR v4的語法定義的思路,更多詳細介紹能夠參照 ANTLR 做者的著做《The Definitive ANTLR 4 Reference》。
ANTLR v4的語法定義文件以語法聲明語句和一系列語法規則語法,大體結構以下:
grammar Name; # 申明名爲Name的語法
# 一次定義語法規則
rule1
rule2
...
ruleN
複製代碼
其中每條語法規則結構如:
ruleName: alternative1 | alternative2 | alternative3 ;
這條語法規則申明一條名爲ruleName
的規則,其中|
表名爲分支、即改規則能夠匹配三個分支中的任何一個。
最後,ANTLR v4 的語法規則分爲詞法(Lexer)規則和語法(Parser)規則:詞法規則定義了怎麼將代碼字符串序列轉換成標記序列;語法規則定義怎麼將標記序列轉換成語法樹。一般,詞法規則的規則名以大寫字母命名,而語法規則的規則名以小寫字母開始。
具體到咱們的Expr語法,定義的語法Expr.g4以下:
grammar Expr;
prog
: stat+ ;
stat
: exprStat
| assignStat
;
exprStat
: expr SEMI
;
assignStat
: ID EQ expr SEMI
;
expr
: expr op = (MUL | DIV ) expr # MulDivExpr
| expr op = ( ADD | SUB ) expr # AddSubExpr
| INT # IntExpr
| ID # IdExpr
| LPAREN expr RPAREN # ParenExpr
;
MUL : '*' ;
DIV : '/' ;
ADD : '+' ;
SUB : '-' ;
LPAREN : '(' ;
RPAREN : ')' ;
ID : LETTER (LETTER | DIGIT)* ;
INT : [0-9]+ ;
EQ : '=' ;
SEMI : ';' ;
COMMENT : '//' ~[\r\n]* '\r'? '\n'? -> channel(HIDDEN);
WS : [ \r\n\t]+ -> channel(HIDDEN);
fragment
LETTER : [a-zA-Z] ;
fragment
DIGIT : [0-9] ;
複製代碼
能夠看到,語法定義是採用自頂向下的設計方法,咱們的Expr代碼以規則prog
做爲根規則,prog
由多條語句stat
組成;而語句stat
能夠是表達式語句exprState
活着賦值語句assignState
;依次向下,到最後一層語法規則表達式expr
,表達式能夠是由表達式組成的加減乘除運算,或者是整數INT
、變量ID
,注意expr
規則使用了遞歸的表達,即在expr
規則的定義中引用了expr
,這也是ANTLR v4的一個特色。 最後這裏定義的詞法規則包含了加減乘除、括號、變量、整數、賦值、註釋和空白等規則;注意其中的註釋(COMMENT
)和空白(WS
)規則定義的channel(HIDDEN)
,這是標記咱們的語法解析須要忽略註釋和空白。
有了語法定義Expr.ge
,就能夠生成咱們須要的解析器源碼了,這裏採用antlr4ts,在package.json中添加script:
"scripts": {
"antlr4ts": "antlr4ts -visitor Expr.g4 -o src/parser"
},
"dependencies": {
"antlr4ts": "^0.4.1-alpha.0"
}
"devDependencies": {
"antlr4ts-cli": "^0.4.0-alpha.4",
"typescript": "^2.5.3",
},
複製代碼
執行 npm run antlr4ts
,就能夠在src/parser
目錄看到生成的Expr解析器的TypeScript源代碼了。
有了Expr語言的解析器,咱們就能夠利用解析器來實現咱們的Expr語言解釋器,具體須要達到的目的即,輸入Expr語言代碼,最後能打印出執行結果。
具體如何利用ANTLR來解釋執行輸入的Expr代碼呢,咱們先看下對如下輸入代碼,ANTLR生成的Token 序列和語法樹是怎樣的?
a = 1;
b = a + 1;
b;
複製代碼
詞法解析獲得的Token序列以下圖所示,共解析爲22個Token,每一個Token包含了Token的序號,Token的文本,Token的類型;如序號爲0的Token,文本爲'a',類型爲'ID',即匹配了咱們上面在Expr.g4
的詞法規則ID
。
語法樹結構以下圖所示,樹中的節點都對應了在Expr.g4
中定義的語法規則或詞法規則,有一點須要注意的是,語法樹中全部的葉子節點都對應到詞法規則或者字符常量,這也是咱們在設計Expr.g4
中自頂向下的設計方法同樣的。
能夠看到,跟節點爲prog
規則節點,它的子節點爲三個語句stat
節點,其中前兩個子節點爲賦值語句assignStat
節點,最後一個的子節點爲表達式語句節點statExpr
。根據在第一部分的定義,針對這段代碼,咱們須要識別出代碼中的表達式語句並打印該表達式的值。具體到這個例子中,這段輸入代碼中只用一個表達式語句,其中的表達式爲變量b,爲了打印b的值,咱們須要經過解釋前兩條語句,計算出b的值(這裏給出捨得,變量的引用必須在變量的定義以後)。因此,總體的思路即咱們須要按順序解釋每條語句,並記住語句解釋過程當中出現的變量和其值,在後續語句的解釋過程當中,若是遇到變量的引用,須要查找該變量的值。
爲了實現上述的解釋過程,咱們須要區遍歷訪問解析器解析出來的語法樹,ANTLR提供了兩種機制來訪問生成的語法樹:Listener和Visitor,使用Listener模式來訪問語法樹時,ANTLR內部的ParserTreeWalker
在遍歷語法樹的節點過程當中,在遇到不一樣的節點中,會調用提供的listener的不一樣方法;而使用Visitor模式時,visitor須要本身來指定若是訪問特定類型的節點,ANTLR生成的解析器源碼中包含了默認的Visitor基類/接口ExprVisitor.ts
,在使用過程當中,只須要對感興趣的節點實現visit方法便可,好比咱們須要訪問到exprStat
節點,只須要實現以下接口:
export interface ExprVisitor<Result> extends ParseTreeVisitor<Result> {
...
/** * Visit a parse tree produced by `ExprParser.exprStat`. * @param ctx the parse tree * @return the visitor result */
visitExprStat?: (ctx: ExprStatContext) => Result;
...
}
複製代碼
介紹完了若是使用Visitor來訪問語法樹中的節點後,咱們來實現Expr解釋器須要的Visitor:ExprEvalVisitor
。
上面提到在訪問語法樹過程當中,咱們須要記錄遇到的變量和其值、和最後的打印結果,咱們使用Visitor內部變量來保存這些中間值:
class ExprEvalVisitor extends AbstractParseTreeVisitor<number>
implements ExprVisitor<number> {
// 保存執行輸出結果
private buffers: string[] = [];
// 保存變量
private memory: { [id: string]: number } = {};
}
複製代碼
咱們須要訪問語法樹中的哪些節點呢?首先,爲了最後的結果,對錶達式語句exprState
的訪問是最重要的,咱們訪問表達式語句中的表達式獲得表達式的值,並將值打印到執行結果中。因爲表達式語句是由表達式加分號組成,咱們須要繼續訪問表達式獲得這條語句的值,而對於分號,則忽略:
class ExprEvalVisitor extends AbstractParseTreeVisitor<number>
implements ExprVisitor<number> {
// 保存執行輸出結果
private buffers: string[] = [];
// 保存變量
private memory: { [id: string]: number } = {};
// 訪問表達式語句
visitExprStat(ctx: ExprStatContext) {
const val = this.visit(ctx.expr());
this.buffers.push(`${val}`);
return val;
}
}
複製代碼
上面遞歸的訪問了表達式語句中的表達式節點,那表達式階段的訪問方法是怎樣的?回到咱們的語法定義Expr.g4,表達式是由5條分支組成的,對於不一樣的分支,處理方法不同,所以咱們對不一樣的分支使用不一樣的訪問方法。咱們在不一樣的分支後面添加了不一樣的註釋,這些註釋生成的解析器中,能夠用來區分不一樣類型的節點,在生成的Visitor中,由能夠看到不一樣的接口:
export interface ExprVisitor<Result> extends ParseTreeVisitor<Result> {
...
/** * Visit a parse tree produced by the `MulDivExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */
visitMulDivExpr?: (ctx: MulDivExprContext) => Result;
/** * Visit a parse tree produced by the `IdExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */
visitIdExpr?: (ctx: IdExprContext) => Result;
/** * Visit a parse tree produced by the `IntExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */
visitIntExpr?: (ctx: IntExprContext) => Result;
/** * Visit a parse tree produced by the `ParenExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */
visitParenExpr?: (ctx: ParenExprContext) => Result;
/** * Visit a parse tree produced by the `AddSubExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */
visitAddSubExpr?: (ctx: AddSubExprContext) => Result;
...
}
複製代碼
因此,在咱們的ExprEvalVisitor
中,咱們經過實現不一樣的接口來訪問不一樣的表達式分支,對於AddSubExpr
分支,實現的訪問方法以下:
visitAddSubExpr(ctx: AddSubExprContext) {
const left = this.visit(ctx.expr(0));
const right = this.visit(ctx.expr(1));
const op = ctx._op;
if (op.type === ExprParser.ADD) {
return left + right;
}
return left - right;
}
複製代碼
對於MulDivExpr
,訪問方法相同。對於IntExpr
分支,因爲其子節點只有INT
節點,咱們只須要解析出其中的整數便可:
visitIntExpr(ctx: IntExprContext) {
return parseInt(ctx.INT().text, 10);
}
複製代碼
對於IdExpr
分支,其子節點只有變量ID
,這個時候就須要在咱們的保存的變量中去查找這個變量,並取出它的值:
visitIdExpr(ctx: IdExprContext) {
const id = ctx.ID().text;
if (this.memory[id] !== undefined) {
return this.memory[id];
}
return 0;
}
複製代碼
對於最後一個分支ParenExpr
,它的訪問方法很簡單,只須要訪問到括號內的表達式便可:
visitParenExpr(ctx: ParenExprContext) {
return this.visit(ctx.expr());
}
複製代碼
到這裏,你能夠發現了,咱們上述的訪問方法加起來,咱們只有從memory讀取變量的過程,沒有想memory寫入變量的過程,這就須要咱們訪問賦值表達式assignExpr
節點了:對於賦值表達式,須要識別出等號左邊的變量名,和等號右邊的表達式,最後將變量名和右邊表達式的值保存到memory中:
visitAssignStat(ctx: AssignStatContext) {
const id = ctx.ID().text;
const val = this.visit(ctx.expr());
this.memory[id] = val;
return val;
}
複製代碼
至此,咱們的VisitorExprEvalVisitor
已經準備好了,咱們只須要在對指定的輸入代碼,使用visitor來訪問解析出來的語法樹,就能夠實現Expr代碼的解釋執行了:
// Expr代碼解釋執行函數
// 輸入code
// 返回執行結果
function execute(code: string): string {
const input = new ANTLRInputStream(code);
const lexer = new ExprLexer(input);
const tokens = new CommonTokenStream(lexer);
const parser = new ExprParser(tokens);
const visitor = new ExprEvalVisitor();
const prog = parser.prog();
visitor.visit(prog);
return visitor.print();
}
複製代碼
經過前面的介紹,咱們已經經過經過ANTLR來解釋執行Expr代碼了。結合ANTLR的介紹:ANTLR是用來讀取、處理、執行和翻譯結構化的文本。那咱們能不能用ANTLR來翻譯輸入的Expr代碼呢?在Expr語言中,表達式是咱們常見的中綴表達式,咱們能將它們翻譯成前綴表達式嗎?還記得數據結構課程中若是利用出棧、入棧將中綴表達式轉換成前綴表達式的嗎?不記得麼關係,利用ANTLR生成的解析器,咱們也能夠簡單的換成轉換。
舉例,對以下Expr代碼:
a = 2;
b = 3;
c = a * (b + 2);
c;
複製代碼
咱們轉換以後的結果以下,咱們支隊表達式作轉換,而對賦值表達式則不作抓換,即代碼中出現的表達式都會轉換成:
a = 2;
b = 3;
c = * a + b 2;
c;
複製代碼
一樣,這裏咱們使用Visitor模式來訪問語法樹,此次,咱們直接visit根節點prog
,並返回翻譯後的代碼:
class ExprTranVisitor extends AbstractParseTreeVisitor<string>
implements ExprVisitor<string> {
defaultResult() {
return '';
}
visitProg(ctx: ProgContext) {
let val = '';
for (let i = 0; i < ctx.childCount; i++) {
val += this.visit(ctx.stat(i));
}
return val;
}
...
}
複製代碼
這裏假設咱們的visitor在visitor語句stat
的時候,已經返回了翻譯的代碼,因此visitProg
只用簡單的拼接每條語句翻譯後的代碼便可。對於語句,前面提到了,語句咱們不作翻譯,因此它們的visit訪問也很簡單:對於表達式語句,直接打印翻譯後的表達式,並加上分號;對於賦值語句,則只需將等號右邊的表達式翻譯便可:
visitExprStat(ctx: ExprStatContext) {
const val = this.visit(ctx.expr());
return `${val};\n`;
}
visitAssignStat(ctx: AssignStatContext) {
const id = ctx.ID().text;
const val = this.visit(ctx.expr());
return `${id} = ${val};\n`;
}
複製代碼
下面看具體如何翻譯各類表達式。對於AddSubExpr
和MulDivExpr
的翻譯,是整個翻譯器的邏輯,即將操做符前置:
visitAddSubExpr(ctx: AddSubExprContext) {
const left = this.visit(ctx.expr(0));
const right = this.visit(ctx.expr(1));
const op = ctx._op;
if (op.type === ExprParser.ADD) {
return `+ ${left} ${right}`;
}
return `- ${left} ${right}`;
}
visitMulDivExpr(ctx: MulDivExprContext) {
const left = this.visit(ctx.expr(0));
const right = this.visit(ctx.expr(1));
const op = ctx._op;
if (op.type === ExprParser.MUL) {
return `* ${left} ${right}`;
}
return `/ ${left} ${right}`;
}
複製代碼
因爲括號在前綴表達式中是沒必要須的,因此的ParenExpr
的訪問,只須要去處括號便可:
visitParenExpr(ctx: ParenExprContext) {
const val = this.visit(ctx.expr());
return val;
}
複製代碼
對於其餘的節點,不須要更多的處理,只須要返回節點對應的標記的文本便可:
visitIdExpr(ctx: IdExprContext) {
const parent = ctx.parent;
const id = ctx.ID().text;
return id;
}
visitIntExpr(ctx: IntExprContext) {
const parent = ctx.parent;
const val = ctx.INT().text;
return val;
}
複製代碼
至此,咱們代碼前綴翻譯的Visitor就準備好了,一樣,執行過程也很簡單,對輸入的代碼,解析生成獲得語法樹,使用ExprTranVisitor
反問prog
根節點,便可返回翻譯後的代碼:
function execute(code: string): string {
const input = new ANTLRInputStream(code);
const lexer = new ExprLexer(input);
const tokens = new CommonTokenStream(lexer);
const parser = new ExprParser(tokens);
const visitor = new ExprTranVisitor();
const prog = parser.prog();
const result = visitor.visit(prog);
return result;
}
複製代碼
對輸入代碼:
A * B + C / D ;
A * (B + C) / D ;
A * (B + C / D) ;
(5 - 6) * 7 ;
複製代碼
執行輸出爲:
+ * A B / C D;
/ * A + B C D;
* A + B / C D;
* - 5 6 7;
複製代碼
經過上述的Expr語言執行器,相信你已經看到了利用ANTLR v4,前端工程師也能夠在瀏覽器段作不少語法解析相關的事情。
想知道咱們是怎樣利用ANTLR解析複雜SQL代碼、對SQL代碼作語法驗證,以及怎麼利用ANTLR來格式化SQL腳本嗎,能夠關注專欄或者發送簡歷至'tao.qit####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~