// 词法规则
TEXT : ~[,\n\r"]+ ;	// TEXT可以是除了回车、逗号之外的任意字符
STRING : '"' ('""'|~'"')* '"' ; // 双引号之间的为一个STRING
// 语法规则
file : hdr row+ ;// 文件 = 头+多行
hdr : row ;// 头
row : field (',' field)* '\r'? '\n' ;// 行=field,field...
field // TEXT 或者STRING
    :   TEXT
    |   STRING

输入1,2,3\na,b,c\na,b,c\n后得到的语法树如下:

本文中都是用IntelliJ IDEA的插件来实现的,有了语法文件就可以生成解析器的代码了,在这之前可以根据自己的需要进行设置:

到这里已经知道怎么弄ANTLR来做一个CSV的分析器了,下面来看看细节。

在一切开始之前需要明白:词法分析器生成TOKEN流给语法分析器使用。也就是说词法分析的字符流来生成TOKEN流,然后语法分析器根据TOKEN流来生成语法规则,在生成代码的时候Visitor、Listener中只有语法规则对应的方法。首先来看一些词法相关的关键字:

  • fragment
  • 第一层:常见的词

    有些词法规则比较通用,比如:

  • 空白字符:WS:[ \t\n\r] -> skip
  • 变量名:ID:[a-zA-Z_]
  • 字符串:STRING:'"' ('\\"' | '\\\\' | .)*? '"'
  • 注释:COMMENT:'//' .*? '\r'? '\n' | '/*' .*? '*/' ->skip
  • 注意到.*?能匹配的到所有的字符,那么注释的为什么能正确地执行?ANTLR在处理该规则的时候会用.*?来匹配最短的字符。用一个简单的词法规则测试一下:

    d : A+;
    A : 'A'.*? 'B';
    

    输入AABAAAAAB的时候有两种分解的方法:一个A或者两个A。而从结果上来看是后者(在写规则的时候需要注意下):

    另外,由于这种优先关系,在STRING我们也不需要关心""之间怎么把'"'排除掉,用起来还是很简单的,感觉有点像优先级。另外,词法分析器中的优先级是先出现的先匹配。

    在上面所有的规则都是用来描述包含的关系,但是在一些时候我们需要排除逻辑,如果是要排除某些字符:

    TEXT:~[,\n\r"]+

    接下来看高级一点的东西:

    第二层:预测和动作

    用书上的Enum作为例子来看,关键部分如下:

    enumDecl : 'enum' name=ID '{' ID (',' ID)* '}' {System.out.println("enum "+$name.text);};
    ENUM :   'enum' {java5}? ;
    ID :   [a-zA-Z]+ ;
    

    需要注意的是:

  • ENUM要写在ID前面
  • enumDecl后面应该是'enum'而不是ENUM
  • 这样达到的效果就是:{java5}?预测失败的时候'enum'为undefined,而不是ID。说的更直白一点就是为了将'enum'从ID词法规则里面踢掉,这样的话就不会去匹配语法规则stat,但是如果换一下顺序:

    ENUM : {java5}? 'enum';
    ID : [a-zA-Z]+ ;

    此时'enum'会有两种可能:ID和undified,然后parser会使用后面的语法规则做进一步的判断,那么此时不管{java5}?能不能验证通过,在输入"enum c{a, b}"的时候都能解析完成,这显然和预期的效果不一样。

    第三层:将TOKEN发送给不同的频道 

    有时候想通过分析注释来生成代码的文档,怎么办?用ANTLR可以将TOKEN分发到不同的channel中:

    他们之间互不干涉,而只有CommonTokenStream是用来交给语法分析器,在词法分析中用下面的方法来设置channel:

    @lexer::members {
    	public static final int WHITESPACE = 1;
    	public static final int COMMENTS = 2;
    WS	:	[ \t\n\r]+ -> channel(WHITESPACE) ; // channel(1)
    SL_COMMENT	:	'//' .*? '\n' -> channel(COMMENTS); // channel(2)
    

    如果只是将一些TOKEN丢掉直接用skip就可以了,一般用channel就会涉及到不同频道中TOKEN的访问,在BufferedTokenStream中提供了API来对其进行访问:

  • getHiddenTokensToRight
  • getHiddenTokensToLeft
  • 在获取到对应的Token列表就可以做相应的操作了。

    第四层:MODE

    很多时候需要将相同的字符串根据不同的环境生成不同类型的TOKEN,如果没有MODE的话只能是根据优先级来做,但是这样会让整体的结构变得非常杂乱,代码的可读性非常差,而且不一定能实现。这种情况下用MODE应该是个不错的选择。定义词法规则如下:

    lexer grammar Test;
    OPEN  : '<'     -> mode(ISLAND) ;
    TEXT  : [a-z] ;
    mode ISLAND;
    CLOSE : '>'     -> mode(DEFAULT_MODE) ;
    ID    : [a-z]+ ;
    

    该规则的目的是实现将"<>"内的字符串定义为类型为ID的TOKEN,此时生成的Test.tokens如下:

    OPEN=1
    CLOSE=3
    TEXT=2
    '<'=1
    '>'=3
    

    随便定义一个语法规则,将词法规则用options{tokenVocab=Test;}引入后生成代码进行测试,对于"<abc>"生成的Token列表为:

    < 1(OPEN)
    abc 4(ID)
    > 3(CLOSE)

    如果没有MODE很多解析做起来还是很头痛的,毕竟字符串的形式就那么几种,而TOKEN的类型是随着你的想法的增多而增多的。在书中给出XML的例子:Lexer&Parser

    在规则的写法上和词法分析器差别不大,但是搞完之后的效果可就十万八千里了:

    第一层:和词法分析器比较

    对语法规则rule : 'A' .*? 'BC'进行测试,在输入AABCBC的时候,解析出来如下:

    可以看到在语法规则中.*?是跟前后的TOKEN有关系的,也就是说此时匹配的实际上是TOKEN。

    第二层:预测和动作

    用书上的Enum作为一个例子来演示语法中预测代码的用法,语法部分有:

    enumDecl : {java5}? 'enum' name=id '{' id (',' id)* '}' {System.out.println("enum "+$name.text);};

    那么在生成的Parser中就会出现:

    public final EnumDeclContext enumDecl() throws RecognitionException {
    	if (!(java5))
    		throw new FailedPredicateException(this, "java5");
    

    也就是说{}?中所写的代码,会在Parser中用if包起来做判断,如果结果为false就不会匹配到后面的规则了。预测语句最好是能保证重复执行也不会出错,如果你写的预测语句如下:

    {$i++ < 10}?

    这样的可能不是一个很好的选择,这种计数类型的一个不错的写法是(匹配指定数目的TOKEN):

    locals [int i=1] : ( {$i<5}? INT {$i++;} )* // 匹配5个INT

    需要注意的一点是,在match的时候会调用consume对TOKEN进行消费。在ACTION中可以访问符号使用变量,如下:

    // 访问词法、语法符号
    variable : type ID ';' {System.out.println($type.text + " " + $ID.text);};
    // 使用变量
    variable : t=type id=ID ';' {System.out.println("type: " + $t.text + " ID: " + $id.text);};
    // 使用+=将符号收集到集合中
    variable : type ids+=ID (',' ids+=ID)* ';'
    System.out.println($type.text);
    for(Object t : $ids)
    	System.out.print(" " + ((Token)t).getText()); 
    

    在生成的代码中语法规则其实就是一个方法,既然是一个方法那么应该可以设置参数返回值,如下:

    variable : type idList[$type.text] {System.out.println($idList.retList + "\r\n" + $idList.count);}';';
    // 带有参数的语法规则
    idList[String typeName] returns [List retList, int count]
    	: ids+=ID (',' ids+=ID)* { $retList = $ids; $count = $ids.size();};
    

    第三层:错误提示

    自己做一个解析器也并不是一件难事,但是如果别人用你的解析器在输入错误的情况下你单单返回一个ERROR,显然是不能接受的,你总得告诉我是在哪里、为什么出错了。在前面写的代码中ANTLR在输出框中打印的错误提示如下:

    在测试语法规则的时候也能给出不错的提示:

    上面这些只是报错的时候才给提示,有时候我想知道语法中的歧义,那么需要:

    parser.getInterpreter().setPredictionMode(PredictionMode.LL_EXACT_AMBIG_DETECTION);
    parser.addErrorListener(new DiagnosticErrorListener());

    很多时候我们需要自己的错误提示,比如:解析程序是在服务端运行,需要将错误提示返回给客户端展示。此时最简单的做法是自己实现一个ANTLRErrorListener

    public interface ANTLRErrorListener {
    	void syntaxError(...);// 语法错误
    	void reportAmbiguity(...);// 歧义
    	void reportAttemptingFullContext(...);// SLL(*)失败,调用ALL(*)的时候调用该方法
    	void reportContextSensitivity(...);// 无歧义
    

    在使用时调用parser.addErrorListener即可。

    这里使用了一个技巧:#Mult使得Visitor中有相应的方法,为了实现加法和乘法,我们在对应的方法中实现逻辑:

        public static class EvalVisitor extends LExprBaseVisitor<Integer> {
            public Integer visitMult(LExprParser.MultContext ctx) {
                return visit(ctx.e(0)) * visit(ctx.e(1));
            public Integer visitAdd(LExprParser.AddContext ctx) {
                return visit(ctx.e(0)) + visit(ctx.e(1));
            public Integer visitInt(LExprParser.IntContext ctx) {
                return Integer.valueOf(ctx.INT().getText());
    

    下面写代码来对计算器进行测试:

    // 对输入进行分析
    ANTLRInputStream input = new ANTLRInputStream("1 + 2");
    LExprLexer lexer = new LExprLexer(input);
    CommonTokenStream tokens = new CommonTokenStream(lexer);
    LExprParser parser = new LExprParser(tokens);
    ParseTree tree = parser.s(); // parse
    // 遍历树并计算结果
    EvalVisitor evalVisitor = new EvalVisitor();
    int result = evalVisitor.visit(tree);
    System.out.println("result = " + result);// result = 3
    

    在这里用到一个小技巧:使用#Mult标记可以使得最后的Visitor中生成对应的方法,也就是说只有visitE跟visitS。。。

    1. @header{}用来将大括号内部的代码插入到XXXParser或者XXXLexer类的头部,通常用来设置package、import。

    2. @members{}用来将代码插入XXXParser或者XXXLexer类内部,是其类的属性,在分析过程中全局可见,通常和ACTION配合实现一些复杂的逻辑。

    3. @init定义了规则函数的初始化代码。

    4. @after定义规则最后执行的代码,通常用来做一些删除缓存、输出等扫尾操作。

    编写过程中遇到的问题

    1、使用locals和returns报错:expecting ARG_ACTION while matching a rule。

    代码如下:

    locals[int i=0] : (TAB {$i++;})* {$i == depth}? 'b' {depth++;} | 'a' | {depth--;}

    找到的解决办法在这里,在ANTLR中要把语法规则放在词法规则前面,不然的话会当成词法规则的关键字来处理。这个明显不合理啊。。。

    2、词法解析时找不到对应的TOKEN,而实际上已经定义过了,代码如下:

    testPath    :   PATH;
    ID          :   [A-Za-z0-9]+;
    PATH        :   ID ('.' | ID)*;
    

    输入abc.abc的时候可以正常解析,输入abc的时候报错:mismatched input 'abc' expecting PATH。其实这个就是典型的优先级导致的,因为abc可以解析成两种:ID 和 PATH,但是根据优先级会被解析成ID,这样语法规则testPath就报这个错误。解决办法是将PATH放在ID前面。