ANTLR:在瀏覽器中玩語法解析

螞蟻金服資料體驗技術發表於2017-12-22

作者簡介 zqlu 螞蟻金服·資料體驗技術團隊

一、前言

在前端開發中,通常提到語法解析等功能,這是都是有後端負責提供介面,前端呼叫。那麼前端是否能自主完成語法解析相關的功能,並在瀏覽器中執行呢?答案是肯定,本文將描述一種簡化的語言稱為Expr語言,並在瀏覽器中完成對輸入的Expr程式碼做錯誤驗證、執行和翻譯等等功能。

二、簡化的Expr語言

首先,介紹下我們的Expr語言,Expr語言是本文假設的一種簡化的類C的語言,程式碼片段如下:

a = 2;
b = 3
c = a * (b + 2);
d = a / (c - 1);

a; // 應該輸出2
b; // 應該輸出3
c;
d;
複製程式碼

前4行的行為是大家熟悉的賦值表示式,最後4行為列印語句,即依次列印各個變數的值,當然這裡也包含了我們熟悉的註釋。

問題來了,我們能在前端自主解析Expr語法的程式碼,並解釋執它們嗎? 如果程式碼片段中有錯誤,我們能給出準確的錯誤提示資訊嗎?還有更多有趣的問題,我們的Expr語言採用了中綴表示式,我們能把輸入程式碼翻譯成字首表示式程式碼嗎?我們能對輸入的程式碼片段做程式碼格式化嗎?我們能把Expr程式碼翻譯成其他語言的原始碼嗎?

首先看看最後的Demo,我們可以在Demo頁面程式碼框輸入Expr語言的程式碼,點選執行按鈕,即可以看到執行後的結果:

Demo

這些功能是在瀏覽器利用ANTLR v4來完成語法解析,並最終實現了語法驗證、解釋執行等功能。

三、初識ANTLR v4

ANTLR的介紹

Antlr4是ANother Tool for Language Recognition即另一個語言識別工具,官方介紹為Antlr4是一款強大的解析器生成工具,可用來讀取、處理、執行和翻譯結構化文字或二進位制檔案。

Antlr4生成的解析器包含了詞法分析程式和語法分析程式,沒錯,這就是編譯原理課程中的詞法分析和語法分析。寫了幾年前端是不是都忘記了,我們只需要知道詞法分析程式是將輸入的程式碼字元序列轉換成標記(Token)序列的程式,而語法分析程式則是將標記序列轉換成語法樹的程式。好在按照Antlr4規範制定了語法定義,Antlr4就可以為我們生成解析器原始碼,它不僅可以生成Java原始碼,還可以生成我們前端方便的JavaScript和TypeScript原始碼。不錯,在本文,我們就是要用Antlr4生成的TypeScript版的解析器,來解析我們的Expr語言程式碼。

ANTLR v4的安裝使用

關於Antlr4的安裝和使用,大家可以參照Github上的Getting Started with ANTLR v4,這裡不作介紹。簡單來說,使用 ANTLR v4,一般分為三步:

  • 按照 ANTLR v4 的編寫待解析語言的語法定義檔案,主流語言的 ANTLR v4 語法定義可以找倉庫antlr/grammars-v4中找到,一般以g4為語法定義檔案字尾
  • 執行 ANTLR 工具,生成指定目標語言的解析器原始碼
  • 使用生成的解析器完成程式碼的解析等

四、Expr語言的語法定義

按照上述的介紹,為了實現解釋執行我們的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語法,定義的語法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語言直譯器,具體需要達到的目的即,輸入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-tokens

語法樹結構如下圖所示,樹中的節點都對應了在Expr.g4中定義的語法規則或詞法規則,有一點需要注意的是,語法樹中所有的葉子節點都對應到詞法規則或者字元常量,這也是我們在設計Expr.g4中自頂向下的設計方法一樣的。

expr-ast-1.png

可以看到,跟節點為prog規則節點,它的子節點為三個語句stat節點,其中前兩個子節點為賦值語句assignStat節點,最後一個的子節點為表示式語句節點statExpr。根據在第一部分的定義,針對這段程式碼,我們需要識別出程式碼中的表示式語句並列印該表示式的值。具體到這個例子中,這段輸入程式碼中只用一個表示式語句,其中的表示式為變數b,為了列印b的值,我們需要通過解釋前兩條語句,計算出b的值(這裡給出捨得,變數的引用必須在變數的定義之後)。所以,整體的思路即我們需要按順序解釋每條語句,並記住語句解釋過程中出現的變數和其值,在後續語句的解釋過程中,如果遇到變數的引用,需要查詢該變數的值。

使用Visitor來訪問語法樹

為了實現上述的解釋過程,我們需要區遍歷訪問解析器解析出來的語法樹,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;
}
複製程式碼

解釋執行Expr語言

至此,我們的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();
}
複製程式碼

六、Expr程式碼字首表示式翻譯器

通過前面的介紹,我們已經通過通過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

同樣,這裡我們使用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`;
}
複製程式碼

下面看具體如何翻譯各種表示式。對於AddSubExprMulDivExpr的翻譯,是整個翻譯器的邏輯,即將操作符前置:

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('####', '@'),歡迎有志之士加入~

原文地址:github.com/ProtoTeam/b…

相關文章