返回

用 ANTLR 精准解析 GraphQL 三引号字符串 (含转义)

java

用 ANTLR 精准解析 GraphQL 三引号字符串

写 ANTLR grammar 时,处理各种字符串字面量,特别是带复杂规则的,有时候挺挠头的。最近就碰到了一个关于 GraphQL 规范里三引号字符串("""...""")的解析问题,它还需要支持 \""" 这种特殊的转义。原有的尝试总是在字符串里多放几个字符就报错,这篇就来彻底搞定它。

问题来了:三引号字符串不好惹

咱们的目标是让 ANTLR 能正确识别 GraphQL 定义的这种三引号字符串。具体规则参考了 这个 GitHub PR。简单说,逻辑上是这样的:

  • StringValue 要么是双引号包起来的 ("..."),要么是三引号包起来的 ("""...""")。
  • 普通双引号字符串 ("...") 里面不能直接出现 "\ 和换行符,需要转义 (\uXXXX, \, \n 等)。
  • 三引号字符串 ("""...""") 里面可以包含几乎任何字符,包括换行,但有俩特殊情况:
    • 不能直接包含三个连续的双引号 """(因为它标志着字符串结束)。
    • 如果想在字符串里表示三个双引号,得用 \""" 来转义。

碰到的具体问题是,下面这个 ANTLR 4 语法片段,只能识别像 """a""" 这种内部只有一个字符的串,一旦变成 """abc""" 就歇菜了:

// 这是有问题的版本
string : triplequotedstring | StringValue ;

triplequotedstring: '"""' triplequotedstringpart?  '"""';

// 问题主要出在这里:'|' 表示“或者”,而不是“混合序列”
triplequotedstringpart : EscapedTripleQuote* | SourceCharacter*;

EscapedTripleQuote : '\\"""'; // 匹配 \"""

SourceCharacter :[\u0009\u000A\u000D\u0020-\uFFFF]; // 匹配大部分可打印字符和空白符

// 普通字符串规则(这里不是重点,先放着)
StringValue: '"' (~(["\\\n\r\u2028\u2029])|EscapedChar)* '"';

测试 """abc""" 时,ANTLR 工具(比如 IntelliJ 插件)会抱怨:

line 1:14 extraneous input 'abc' expecting {'"""', '\\"""', SourceCharacter}

意思是解析到 abc 这块儿就卡住了,不知道该咋办。

为什么会失败?刨根问底

仔细看看失败的语法:triplequotedstringpart : EscapedTripleQuote* | SourceCharacter*;

这行规则的问题在于那个 | (或) 操作符。它的意思是 triplequotedstringpart 要么匹配 零个或多个 EscapedTripleQuote要么 匹配 零个或多个 SourceCharacter。它无法处理 EscapedTripleQuoteSourceCharacter 混合出现 的情况,比如 """a\"""b"""

我们期望的是匹配一个 序列,这个序列可以包含任意数量的 EscapedTripleQuote 和/或 SourceCharacter(当然,这里的 SourceCharacter 定义也需要调整,不能直接包含 ",特别是不能构成结束符 """)。

更深层的原因在于,处理这种带有特殊结束符和转义规则的“长字符串”,直接用 parser 规则去一点点拼凑(像 triplequotedstringpart 这样尝试)通常很麻烦,而且容易出错。ANTLR 的 Lexer(词法分析器)其实更擅长干这事儿。Lexer 负责把原始输入切分成一个个 Token,它有更灵活的机制来处理这类“一直读,直到遇到某个模式为止”的需求。

如果硬用 parser 规则,parser 在处理 """abc""" 时,可能会把 'a', 'b', 'c' 分别识别成独立的 SourceCharacter token(如果 SourceCharacter 定义得足够宽泛),但它缺乏一个整体的上下文来判断这些字符是否应该属于一个三引号字符串内部。

解决方案:亮出 ANTLR 组合拳

解决这个问题的最佳实践通常是:把整个三引号字符串(包括起始和结束的 """)定义成一个单独的 Lexer 规则(Token) 。这样,Parser 规则就变得非常简单,只需要匹配这个特定的 Token 就行了。

核心思路:交给 Lexer 处理

让 Lexer 来识别整个 """...""" 结构。Lexer 规则可以利用 ANTLR 提供的非贪婪匹配 (*?) 和字符集排除等功能,精确地匹配从开始的 """ 到结束的 """ 之间的所有内容,同时正确处理 \""" 转义。

定义 Lexer 规则

我们需要一个 Lexer 规则,比如叫 TRIPLE_QUOTED_STRING,它能完成以下任务:

  1. 匹配开头的 """
  2. 匹配中间的内容:
    • 如果遇到 \""",把它当作普通内容的一部分。
    • 如果遇到任何其他字符(包括 \ 后面跟的任何字符,用于处理 \n, \\ 等标准转义),也当作内容。
    • 一直匹配,直到遇到第一个没有\ 转义的 """
  3. 匹配结尾的 """

下面是一个推荐的 Lexer 规则定义:

lexer grammar GraphQLStringLexer; // 假设 Lexer 文件名

// 核心:三引号字符串 Token 定义
TRIPLE_QUOTED_STRING
    :   '"""'           // 匹配起始的三个引号
        (               // 开始匹配中间内容
            '\\"""'     // 优先匹配转义的 \"""
            |           // 或者
            '\\' .      // 匹配任何转义字符(如 \n, \t, \\ 等)
            |           // 或者
            ~["\\]      // 匹配任何不是 " 也不是 \ 的字符
        )*?             // ...重复零次或多次,并且是非贪婪模式!这点很重要!
        '"""'           // 匹配结束的三个引号
    ;

// 普通双引号字符串 Token (为了完整性也给出)
// 注意:这里的实现需要根据你的具体需求调整,特别是转义部分
// 这个简单版本仅处理 \", \\ 和 \uXXXX,不处理 \n 等
STRING
    :   '"'
        (   '\\"'       // 转义 "
        |   '\\\\'      // 转义 \
        |   '\\u' [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] // Unicode 转义
        |   ~["\\\r\n] // 匹配任何不是 ", \ 或换行的字符
        )*
        '"'
    ;

// 其他可能需要的 Token
IDENTIFIER: [a-zA-Z_] [a-zA-Z0-9_]* ; // 示例标识符
WS: [ \t\r\n]+ -> skip ; // 跳过空白符

解释规则各部分 (TRIPLE_QUOTED_STRING):

  • '"""':字面量匹配,精确匹配三个双引号。
  • ( ... )*?:这是匹配核心内容的部分。
    • *?:表示非贪婪匹配。它会尽可能少地匹配括号内的模式,直到后面的 '"""' 能匹配上为止。这是处理“匹配直到 X”场景的关键。
    • '\\"""':直接匹配转义序列 \"""。把它放在 | 的前面,让 Lexer 优先尝试匹配这个,避免 \ 被下面的 \\ .~["\\] 错误地吃掉一部分。
    • '\\' .:匹配一个反斜杠 \ 后面跟着的任何 单个字符。这覆盖了所有标准的单字符转义,比如 \n, \t, \\,甚至是 \a 这种无效转义(如果你的语言规范允许或需要后续处理)。. 是 ANTLR 中匹配任意单个字符的通配符。
    • ~["\\]:匹配除了 双引号 " 和反斜杠 \ 之外的任何单个字符。使用 ~ (取反) 加上字符集 ["\\] 来实现。为什么排除 "\?因为 " 可能是结束符 """ 的一部分,而 \ 是转义符的开始,它们需要被前面两个 | 分支特殊处理。
  • 最后的 '"""':匹配结束的三个引号。

这个 Lexer 规则组合起来,就能精确地捕捉从 """ 开始,到第一个非转义的 """ 结束的所有内容,包括内部的 \""" 转义和各种其他字符及转义。

Parser 规则怎么写?

有了上面强大的 TRIPLE_QUOTED_STRING Lexer 规则后,对应的 Parser 规则就简单到不行了:

parser grammar GraphQLStringParser; // 假设 Parser 文件名
options { tokenVocab = GraphQLStringLexer; } // 引用 Lexer

// 顶层规则示例,比如解析一个值
value: stringValue ;

// stringValue 规则现在非常简洁
stringValue
    : STRING                 // 匹配普通双引号字符串 Token
    | TRIPLE_QUOTED_STRING   // 匹配三引号字符串 Token
    ;

// 其他 parser 规则...

现在,无论是 "hello" 还是 """hello\nworld\""" GraphQL""" 这种复杂的字符串,都会被 Lexer 正确地识别为 STRINGTRIPLE_QUOTED_STRING 类型的 Token。Parser 只需要根据 Token 类型进行处理即可。

代码实战与测试

我们用 Java (ANTLR 的常用目标语言) 来演示一下怎么使用这个语法。假设你已经用 ANTLR 工具根据上面的 .g4 文件生成了 Lexer 和 Parser 代码。

import org.antlr.v4.runtime.*;
import org.antlr.v4.runtime.tree.*;

// 假设生成的 Lexer 叫 GraphQLStringLexer
// 假设生成的 Parser 叫 GraphQLStringParser
// 假设你的入口规则叫 value

public class TestGraphQLString {

    public static void main(String[] args) {
        String[] testInputs = {
            "\"simple string\"",
            "\"string with \\\" quote\"",
            "\"string with \\\\ backslash\"",
            "\"string with \\u0020 space\"", // Unicode space
            "\"\"", // Empty double quoted string
            "\"\"\"simple block string\"\"\"",
            "\"\"\"block string\nwith newlines\"\"\"",
            "\"\"\"block string with \\\"\"\" escaped triple quote\"\"\"",
            "\"\"\"block string with \\\\ backslash\"\"\"",
            "\"\"\"\"\"\"", // Empty block string
            "\"\"\" a \" b \"\"\" c \"\"\"" // Contains internal quotes and spaces
        };

        for (String input : testInputs) {
            System.out.println("Testing input: " + input);
            try {
                CharStream charStream = CharStreams.fromString(input);
                GraphQLStringLexer lexer = new GraphQLStringLexer(charStream);
                CommonTokenStream tokens = new CommonTokenStream(lexer);
                GraphQLStringParser parser = new GraphQLStringParser(tokens);

                // 设置错误监听器,以便更好地看到错误
                parser.removeErrorListeners(); // 移除默认的 ConsoleErrorListener
                parser.addErrorListener(new BaseErrorListener() {
                    @Override
                    public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol,
                                            int line, int charPositionInLine, String msg, RecognitionException e) {
                        System.err.println("ERROR line " + line + ":" + charPositionInLine + " " + msg);
                    }
                });

                // 开始解析,从 'value' 规则开始
                ParseTree tree = parser.value(); // 调用你的入口规则

                System.out.println("Parse successful!");
                // 你可以在这里添加 Visitor 或 Listener 来处理解析树
                // 例如,获取 Token 文本:
                if (tree instanceof GraphQLStringParser.ValueContext) {
                    GraphQLStringParser.ValueContext valueCtx = (GraphQLStringParser.ValueContext) tree;
                    if (valueCtx.stringValue() != null && valueCtx.stringValue().getStart() != null) {
                         Token stringToken = valueCtx.stringValue().getStart();
                         String rawText = stringToken.getText();
                         // 注意:getText() 返回的是包含引号和转义符的原始文本
                         // 你需要额外处理来得到实际的字符串内容
                         System.out.println("  Token Type: " + GraphQLStringLexer.VOCABULARY.getSymbolicName(stringToken.getType()));
                         System.out.println("  Raw Text: " + rawText);
                         System.out.println("  (Need post-processing for actual content)");
                    }
                }
                 System.out.println("-" .repeat(20));

            } catch (Exception e) {
                System.err.println("Parse failed for input: " + input);
                e.printStackTrace(System.err);
                 System.out.println("-" .repeat(20));
            }
        }
    }
}

运行这段 Java 代码(确保 ANTLR runtime 库在 classpath 中),它会逐个测试输入字符串。使用我们改进后的 Lexer 规则,所有的测试用例都应该能成功解析。你会看到每个输入对应的 Token 类型(STRINGTRIPLE_QUOTED_STRING)和原始文本。

重点: Lexer Token 的 getText() 方法返回的是匹配到的原始文本,包括两端的引号和内部的转义序列(比如 \\n, \\""")。如果你需要得到“解码”后的实际字符串值(比如把 \\n 替换成真正的换行符,把 \\""" 替换成 """),通常需要在拿到 Token 后,在你的应用程序代码(比如 Visitor 或 Listener 中)进行二次处理。

安全和注意事项

  • 非贪婪匹配 (*?) 的性能: 非贪婪匹配通常比贪婪匹配稍微慢一点点,因为它需要向前看(lookahead)来确定何时停止。但在大多数场景下,这点性能差异微乎其微,为了语法的正确性和简洁性,使用非贪婪匹配是值得的。只有在处理极端巨大的文件,并且字符串解析成为瓶颈时,才可能需要考虑更复杂的优化。
  • 与其他规则的交互: 确保你的 TRIPLE_QUOTED_STRING 规则不会意外地与其他 Lexer 规则(比如注释、操作符等)发生冲突。ANTLR Lexer 默认是贪婪的,它会尝试匹配最长的可能 Token。'\\"""' 规则放在前面有助于确保它优先于更通用的 .~[...] 被匹配。
  • Unicode 处理: 上面的 ~["\\] 能正确处理大部分 Unicode 字符。如果你的输入源编码不是 UTF-8,或者需要特殊处理某个范围的 Unicode 字符,可能需要调整 Lexer 选项或规则细节。

进阶玩法与优化

虽然上面的方案已经能很好地解决问题了,但还有些可以琢磨的地方。

处理 Unicode 转义 (\uXXXX)

GraphQL 规范也允许在普通字符串和三引号字符串中使用 \uXXXX Unicode 转义。我们上面的 TRIPLE_QUOTED_STRING 规则里的 \\ . 部分已经能匹配 \u 了,但如果你想在 Lexer 层面就验证 XXXX 是有效的十六进制数字,可以扩展规则:

TRIPLE_QUOTED_STRING
    :   '"""'
        (   '\\"""'     // Escaped triple quote
            | '\\u' HEX_DIGIT HEX_DIGIT HEX_DIGIT HEX_DIGIT // Unicode escape
            | '\\' .      // Other escapes (\n, \t, \\, etc.)
            | ~["\\]      // Any char except " or \
        )*?
        '"""'
    ;

// 需要定义一个 fragment 规则用于十六进制数字
fragment HEX_DIGIT: [0-9a-fA-F] ;

fragment 规则本身不产生 Token,它只是 Lexer 规则内部的辅助片段。这样改动后,\u 后面必须跟 4 个十六进制数字才会被视为有效转义(在 Lexer 层面)。

模式(Mode)切换的小技巧

对于更复杂的词法分析场景,比如处理模板字符串内部可能嵌套代码块的情况,ANTLR 的 Lexer 模式 (mode) 是个强大的工具。你可以定义不同的模式,在遇到特定标记时切换 Lexer 的状态,让它使用不同的规则集。

不过,对于仅仅解析三引号字符串这个问题,模式显得有点杀鸡用牛刀了,当前的单 Lexer 规则足够清晰有效。

性能考量

再次强调,非贪婪匹配 *? 在绝大多数情况下工作得很好。如果真的遇到了性能瓶颈(可能性不高),一种可能的优化思路是避免使用 . 通配符和非贪婪匹配,改为更明确地列出所有允许的字符或序列,但这通常会让规则变得更复杂、更难维护。先用简洁清晰的方案,只在必要时才进行性能驱动的重构。

现在,有了这个健壮的 Lexer 规则,解析 GraphQL 的三引号字符串应该不再是难题了。把复杂性封装在 Lexer 里,让 Parser 保持简单,这通常是 ANTLR 使用中的一个好策略。