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

做者簡介 zqlu 螞蟻金服·數據體驗技術團隊前端

1、前言

在前端開發中,一般提到語法解析等功能,這是都是有後端負責提供接口,前端調用。那麼前端是否能自主完成語法解析相關的功能,並在瀏覽器中運行呢?答案是確定,本文將描述一種簡化的語言稱爲Expr語言,並在瀏覽器中完成對輸入的Expr代碼作錯誤驗證、執行和翻譯等等功能。git

2、簡化的Expr語言

首先,介紹下咱們的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

Demo

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

3、初識ANTLR v4

ANTLR的介紹

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

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

ANTLR v4的安裝使用

關於Antlr4的安裝和使用,你們能夠參照Github上的Getting Started with ANTLR v4,這裏不做介紹。簡單來講,使用 ANTLR v4,通常分爲三步:前端工程師

  • 按照 ANTLR v4 的編寫待解析語言的語法定義文件,主流語言的 ANTLR v4 語法定義能夠找倉庫antlr/grammars-v4中找到,通常以g4爲語法定義文件後綴
  • 運行 ANTLR 工具,生成指定目標語言的解析器源代碼
  • 使用生成的解析器完成代碼的解析等

4、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源代碼了。

5、 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();
}
複製代碼

6、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;
複製代碼

7、總結

經過上述的Expr語言執行器,相信你已經看到了利用ANTLR v4,前端工程師也能夠在瀏覽器段作不少語法解析相關的事情。

想知道咱們是怎樣利用ANTLR解析複雜SQL代碼、對SQL代碼作語法驗證,以及怎麼利用ANTLR來格式化SQL腳本嗎,能夠關注專欄或者發送簡歷至'tao.qit####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~

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

相關文章
相關標籤/搜索