ANTLR 大型教程

位置:首页>文章>详情   分类: Java教程 > 编程技术   阅读(495)   2023-10-25 15:34:57

解析器是强大的工具,使用 ANTLR 可以编写可用于多种不同语言的各种解析器。

在这个完整的教程中,我们将:

  • 解释基础:什么是解析器,它可以用来做什么
  • 查看如何设置 ANTLR 以便在 Javascript、Python、Java 和 C# 中使用
  • 讨论如何测试您的解析器
  • 展示 ANTLR 中最先进和有用的功能:您将学习解析所有可能的语言所需的一切
  • 显示大量示例

也许您已经阅读了一些过于复杂或过于片面的教程,似乎假设您已经知道如何使用解析器。这不是那种教程。我们只希望您知道如何编码以及如何使用文本编辑器或 IDE。就是这样。

在本教程结束时:

  • 你将能够编写一个解析器来识别不同的格式和语言
  • 您将能够创建构建词法分析器和解析器所需的所有规则
  • 您将知道如何处理您将遇到的常见问题
  • 您将理解错误,并知道如何通过测试语法来避免错误。

换句话说,我们将从头开始,当我们到达终点时,您将了解所有可能需要了解的 ANTLR。

ANTLR Mega Tutorial 庞大的内容列表

什么是 ANTLR?

ANTLR 是一个解析器生成器,一个帮助您创建解析器的工具。 解析器获取一段文本并将其转换为有组织的结构,例如抽象语法树 (AST)。您可以将 AST 视为描述代码内容的故事,或者也可以将其视为通过将各个部分组合在一起而创建的逻辑表示。

欧几里德算法的 AST 的图形表示

获得 AST 需要做什么:

  1. 定义词法分析器和语法分析器
  2. 调用 ANTLR:它将以您的目标语言(例如,Java、Python、C#、Javascript)生成词法分析器和解析器
  3. 使用生成的词法分析器和解析器:你调用它们传递代码进行识别,它们返回给你一个 AST

因此,您需要首先为要分析的内容定义词法分析器和解析器语法。通常“事物”是一种语言,但它也可以是一种数据格式、图表或任何一种用文本表示的结构。

正则表达式还不够吗?

如果您是典型的程序员,您可能会问自己为什么我不能使用正则表达式?正则表达式非常有用,比如当你想在一串文本中查找数字时,但它也有很多限制。

最明显的是缺少递归:你无法在另一个表达式中找到(正则)表达式,除非你为每个级别手动编写代码。很快就变得无法维护的东西。但更大的问题是它不是真正可扩展的:如果你只是将几个正则表达式放在一起,你将创建一个难以维护的脆弱混乱。

正则表达式没那么好用

你试过用正则表达式解析 HTML 吗?这是一个糟糕的想法,因为你冒着召唤 Cthulhu 的风险,但更重要的是,它实际上行不通。你不相信我?让我们看看,你想找到一个表的元素,所以你尝试像这样的常规表达式:<table>(.*?)</table>。杰出的!你做到了!除非有人向他们的表添加属性,例如 styleid。没关系,你做这个<table.*?>(.*?)</table>,但是你其实关心的是表里面的数据,所以你接下来需要解析trtd,但它们充满了标签。

所以你也需要消除它。甚至有人敢使用像 这样的评论。注释可以在任何地方使用,这不容易用正则表达式来处理。是吗?

所以你禁止互联网在 HTML 中使用评论:问题解决了。

或者你也可以使用 ANTLR,无论你觉得更简单。

ANTLR 与手动编写自己的解析器

好吧,你被说服了,你需要一个解析器,但为什么要使用像 ANTLR 这样的解析器生成器而不是自己构建呢?

ANTLR 的主要优势是生产力

如果您实际上必须一直使用解析器,因为您的语言或格式在不断发展,那么您需要能够跟上步伐,如果您必须处理实现解析器的细节,则无法做到这一点解析器。由于您不是为了解析而解析,因此您必须有机会专注于实现您的目标。而 ANTLR 使它更容易、快速和干净地做到这一点。

其次,一旦定义了语法,就可以要求 ANTLR 生成不同语言的多个解析器。例如,您可以获得 C# 中的解析器和 Javascript 中的解析器,以在桌面应用程序和 Web 应用程序中解析相同的语言。

有些人争辩说,手动编写解析器可以使其更快,并且可以生成更好的错误消息。这有一定的道理,但根据我的经验,ANTLR 生成的解析器总是足够快。如果确实需要,您可以通过处理语法来调整它们并提高性能和错误处理。一旦您对语法感到满意,就可以这样做。

目录或好吧,我相信,告诉我你得到了什么

两个小笔记:

  • 在本教程的配套存储库中,您将找到所有经过测试的代码,即使我们在本文中没有看到的代码也是如此
  • 示例将使用不同的语言,但知识将普遍适用于任何语言

设置

  1. 设置 ANTLR
  2. Javascript 设置
  3. Python 设置
  4. Java 设置
  5. C# 设置

初学者

  1. 词法分析器和解析器
  2. 创建语法
  3. 设计数据格式
  4. 词法分析器规则
  5. 解析器规则
  6. 错误与调整

中级

  1. 在 Javascript 中设置聊天项目
  2. Antlr.js
  3. HtmlChatListener.js
  4. 与听众一起工作
  5. 使用语义谓词解决歧义
  6. 用 Python 继续聊天
  7. 使用监听器的 Python 方式
  8. 使用 Python 进行测试
  9. 解析标记
  10. 词法模式
  11. 语法分析器

先进的

  1. Java 中的标记项目
  2. 主 App.java
  3. 使用 ANTLR 转换代码
  4. 转换代码的快乐与痛苦
  5. 高级测试
  6. 处理表达式
  7. 解析电子表格
  8. C# 中的电子表格项目
  9. Excel 注定失败
  10. 测试一切

最后的评论

  1. 提示和技巧
  2. 总结

设置

在本节中,我们准备我们的开发环境以使用 ANTLR:解析器生成器工具、支持工具和每种语言的运行时。

1.设置ANTLR

ANTLR 实际上由两个主要部分组成:用于生成词法分析器和解析器的工具,以及运行它们所需的运行时。

作为语言工程师,您将只需要该工具,而运行时将包含在使用您的语言的最终软件中。

无论您的目标是哪种语言,该工具总是相同的:它是您在开发机器上需要的 Java 程序。虽然每种语言的运行时都不同,并且必须对开发人员和用户都可用。

使用该工具的唯一要求是您至少安装了 Java 1.7。要安装 Java 程序,您需要从官方网站下载最新版本,目前为:

http://www.antlr.org/download/antlr-4.6-complete.jar

指示

  1. 将下载的工具复制到通常放置第三方 java 库(例如 /usr/local/lib C:\Program Files\Java\lib)的地方
  2. 将工具添加到您的 CLASSPATH。将它添加到您的启动脚本(例如 .bash_profile
  3. (可选)也为您的启动脚本添加别名以简化 ANTLR 的使用

在 Linux/Mac OS 上执行指令

// 1.
sudo cp antlr-4.6-complete.jar /usr/local/lib/
// 2. and 3.
// add this to your .bash_profile
export CLASSPATH=".:/usr/local/lib/antlr-4.6-complete.jar:$CLASSPATH"
// simplify the use of the tool to generate lexer and parser
alias antlr4='java -Xmx500M -cp "/usr/local/lib/antlr-4.6-complete.jar:$CLASSPATH" org.antlr.v4.Tool'
// simplify the use of the tool to test the generated code
alias grun='java org.antlr.v4.gui.TestRig'

在 Windows 上执行指令

// 1.
Go to System Properties dialog > Environment variables
-> Create or append to the CLASSPATH variable
// 2. and 3. Option A: use doskey
doskey antlr4=java org.antlr.v4.Tool $*
doskey grun =java org.antlr.v4.gui.TestRig $*
// 2. and 3. Option B: use batch files
// create antlr4.bat
java org.antlr.v4.Tool %*
// create grun.bat
java org.antlr.v4.gui.TestRig %*
// put them in the system path or any of the directories included in %path%

典型工作流程

当您使用 ANTLR 时,您首先要编写一个语法,一个扩展名为 .g4 的文件,其中包含您正在分析的语言的规则。然后使用 antlr4 程序生成您的程序将实际使用的文件,例如词法分析器和解析器。

antlr4 <options> <grammar-file-g4>

在运行 antlr4 时,您可以指定几个重要的选项。

首先,您可以指定目标语言,以 Python 或 JavaScript 或任何其他不同于 Java(默认语言)的目标生成解析器。其他的用于生成visitor和listener(如果你不知道这些是什么不用担心,我们稍后会解释)。

默认情况下只生成侦听器,因此要创建访问者,请使用 -visitor 命令行选项,如果不想生成侦听器,则使用 -no-listener。还有相反的选项,-no-visitor-listener,但它们是默认值。

antlr4 -visitor <Grammar-file>

您可以选择使用一个名为 TestRig ( 的小实用程序来测试您的语法,尽管正如我们所见,它通常是 grun 的别名。

grun <grammar-name> <rule-to-test> <input-filename(s)>

文件名是可选的,您可以改为分析您在控制台上键入的输入。

如果你想使用测试工具,你需要生成一个 Java 解析器,即使你的程序是用另一种语言编写的。这可以通过使用 antlr4 选择不同的选项来完成。

Grun 在手动测试语法初稿时很有用。随着它变得更加稳定,您可能希望依赖自动化测试(我们将看到如何编写它们)。

Grun 还有一些有用的选项:-tokens,用于显示检测到的标记,-gui 用于生成 AST 的图像。

2.Javascript 设置

您可以将语法放在与 Javascript 文件相同的文件夹中。包含语法的文件必须与语法同名,必须在文件顶部声明。

在以下示例中,名称为 Chat,文件为 Chat.g4

我们可以简单地通过使用 ANTLR4 Java 程序指定正确的选项来创建相应的 Javascript 解析器。

antlr4 -Dlanguage=JavaScript Chat.g4

请注意,该选项区分大小写,因此请注意大写的“S”。如果您输入有误,您将收到如下消息。

error(31):  ANTLR cannot generate Javascript code as of version 4.6

ANTLR 可以与 node.js 一起使用,也可以在浏览器中使用。对于浏览器,您需要使用 webpackrequire.js。如果您不知道如何使用这两者中的任何一个,您可以查看官方文档以获取帮助,或者阅读有关 antlr 的网络教程。我们将使用 node.js,您只需使用以下标准命令即可为其安装 ANTLR 运行时。

npm install antlr4

3. Python 设置

当你有一个语法时,你将它放在与你的 Python 文件相同的文件夹中。该文件必须与语法同名,必须在文件顶部声明。在以下示例中,名称为 Chat,文件为 Chat.g4

我们可以简单地通过使用 ANTLR4 Java 程序指定正确的选项来创建相应的 Python 解析器。对于Python,还需要注意Python的版本,2还是3。

antlr4 -Dlanguage=Python3 Chat.g4

运行时可从 PyPi 获得,因此您只需使用 pio 即可安装它。

pip install antlr4-python3-runtime

同样,您只需要记住指定正确的 python 版本。

4.Java设置

要使用 ANTLR 设置我们的 Java 项目,您可以手动执行操作。或者做个文明人,用Gradle或者Maven。

此外,您可以查看适用于您的 IDE 的 ANTLR 插件。

4.1 使用 Gradle 进行 Java 设置

这就是我通常设置 Gradle 项目的方式。

我使用 Gradle 插件调用 ANTLR,我还使用 IDEA 插件为 IntelliJ IDEA 生成配置。

dependencies {
  antlr "org.antlr:antlr4:4.5.1"
  compile "org.antlr:antlr4-runtime:4.5.1"
  testCompile 'junit:junit:4.12'
}
 
generateGrammarSource {
    maxHeapSize = "64m"
    arguments += ['-package', 'me.tomassetti.mylanguage']
    outputDirectory = new File("generated-src/antlr/main/me/tomassetti/mylanguage".toString())
}
compileJava.dependsOn generateGrammarSource
sourceSets {
    generated {
        java.srcDir 'generated-src/antlr/main/'
    }
}
compileJava.source sourceSets.generated.java, sourceSets.main.java
 
clean{
    delete "generated-src"
}
 
idea {
    module {
        sourceDirs += file("generated-src/antlr/main")
    }
}

我将我的语法放在 src/main/antlr/ 下,gradle 配置确保它们在与其包对应的目录中生成。例如,如果我希望解析器在包 me.tomassetti.mylanguage 中,它必须生成到 generated-src/antlr/main/me/tomassetti/mylanguage

此时我可以简单地运行:

# Linux/Mac
./gradlew generateGrammarSource
 
# Windows
gradlew generateGrammarSource

我得到了从我的语法生成的词法分析器和解析器。

然后我也可以运行:

# Linux/Mac
./gradlew idea
 
# Windows
gradlew idea

我有一个 IDEA 项目准备打开。

4.2 使用 Maven 的 Java 设置

首先,我们将在 POM 中指定我们需要 antlr4-runtime 作为依赖项。我们还将使用 Maven 插件通过 Maven 运行 ANTLR。

我们还可以指定 ANTLR 是否生成访问者或侦听器。为此,我们定义了几个相应的属性。

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
 
  [..]
 
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <antlr4.visitor>true</antlr4.visitor>
    <antlr4.listener>true</antlr4.listener>
  </properties>  
 
  <dependencies>
    <dependency>
      <groupId>org.antlr</groupId>
      <artifactId>antlr4-runtime</artifactId>
      <version>4.6</version>
    </dependency>
   [..]
  </dependencies>
 
  <build>
    <plugins>
      [..]
      <!-- Plugin to compile the g4 files ahead of the java files
           See https://github.com/antlr/antlr4/blob/master/antlr4-maven-plugin/src/site/apt/examples/simple.apt.vm
           Except that the grammar does not need to contain the package declaration as stated in the documentation (I do not know why)
           To use this plugin, type:
             mvn antlr4:antlr4
           In any case, Maven will invoke this plugin before the Java source is compiled
        -->
      <plugin>
        <groupId>org.antlr</groupId>
        <artifactId>antlr4-maven-plugin</artifactId>
        <version>4.6</version>                
        <executions>
          <execution>
            <goals>
              <goal>antlr4</goal>
            </goals>            
          </execution>
        </executions>
      </plugin>
      [..]
    </plugins>
  </build>
</project>

现在您必须将语法的 *.g4 文件放在 src/main/antlr4/me/tomassetti/examples/MarkupParser.

一旦你编写了你的语法,你只需运行 mvn package ,所有的魔法就会发生:ANTLR 被调用,它生成词法分析器和解析器,并将它们与你的代码的其余部分一起编译。

// use mwn to generate the package
mvn package

如果您从未使用过 Maven,您可以查看 Java 目标的官方 ANTLR 文档Maven 网站 以帮助您入门。

使用 Java 开发 ANTLR 语法有一个明显的优势:有多个 IDE 的插件,而且它是该工具的主要开发人员实际使用的语言。所以它们是像 org.antlr.v4.gui.TestRig 这样的工具,可以很容易地集成到您的工作流程中,如果您想轻松地可视化输入的 AST,它们会很有用。

5.C#设置

支持 .NET Framework 和 Mono 3.5,但不支持 .NET core。我们将使用 Visual Studio 创建我们的 ANTLR 项目,因为 C# 目标的同一作者为 Visual Studio 创建了一个很好的扩展,称为 ANTLR 语言支持。您可以通过进入工具 -> 扩展和更新来安装它。当您构建项目时,此扩展将自动生成解析器、词法分析器和访问者/侦听器。

此外,扩展将允许您创建一个新的语法文件,使用众所周知的菜单添加一个新项目。最后但同样重要的是,您可以设置选项以在每个语法文件的属性中生成侦听器/访问器。

或者,如果您更喜欢使用编辑器,则需要使用常用的 Java 工具来生成所有内容。您只需指明正确的语言即可做到这一点。在此示例中,语法称为“Spreadsheet”。

antlr4 -Dlanguage=CSharp Spreadsheet.g4

请注意,CSharp 中的“S”是大写的。

您的项目仍然需要 ANTLR4 运行时,您可以使用很好的 nuget 安装它。

初学者

在本节中,我们为您奠定使用 ANTLR 所需的基础:什么是词法分析器和解析器、在文法中定义它们的语法以及可用于创建它们的策略。我们还看到了第一个示例,以展示如何使用您所学的知识。如果您不记得 ANTLR 是如何工作的,可以返回到本节。

6.词法分析器和解析器

在研究解析器之前,我们需要先研究词法分析器,也称为分词器。它们基本上是迈向解析器的第一步,当然 ANTLR 也允许您构建它们。 词法分析器获取单个字符并将它们转换为标记,解析器使用原子来创建逻辑结构。

想象一下将此过程应用于自然语言(例如英语)。您正在阅读单个字符,将它们放在一起直到它们组成一个单词,然后您将不同的单词组合成一个句子。

让我们看看下面的例子,想象一下我们正在尝试解析一个数学运算。

437 + 734

词法分析器扫描文本并找到“4”、“3”、“7”,然后是空格“”。所以它知道第一个字符实际上代表一个数字。然后它找到一个“+”符号,所以它知道它代表一个运算符,最后它找到另一个数字。

它怎么知道的?因为我们告诉它。

/*
 * Parser Rules
 */
 
operation  : NUMBER '+' NUMBER ;
 
/*
 * Lexer Rules
 */
 
NUMBER     : [0-9]+ ;
 
WHITESPACE : ' ' -> skip ;

这不是一个完整的语法,但是我们已经可以看到词法分析器规则都是大写的,而解析器规则都是小写的。从技术上讲,关于大小写的规则仅适用于他们名字的第一个字符,但为了清楚起见,通常他们都是大写或小写。

规则通常按以下顺序编写:首先是解析器规则,然后是词法分析器规则,尽管逻辑上它们的应用顺序相反。同样重要的是要记住,词法分析器规则是按照它们出现的顺序进行分析的,它们可能是模棱两可的。

典型的例子是标识符:在许多编程语言中,它可以是任何字母串,但某些组合,例如“class”或“function”是被禁止的,因为它们表示 class功能。所以规则的顺序通过使用第一个匹配来解决歧义,这就是为什么首先定义标识关键字的标记,例如 classfunction,而标识关键字的标记放在最后。

规则的基本语法很简单:有一个名称、一个冒号、规则的定义和一个终止分号

NUMBER 的定义包含一个典型的数字范围和一个“+”符号以指示允许一个或多个匹配项。这些都是我假设您熟悉的非常典型的指示,如果不熟悉,您可以阅读有关正则表达式 语法的更多信息。

最有趣的部分在最后,定义 WHITESPACE 标记的词法分析器规则。这很有趣,因为它展示了如何指示 ANTLR 忽略某些内容。考虑忽略空格如何简化解析器规则:如果我们不能说忽略 WHITESPACE,我们将不得不在解析器的每个子规则之间包含它,让用户在他想要的地方放置空格。像这样:

operation  : WHITESPACE* NUMBER WHITESPACE* '+' WHITESPACE* NUMBER;

这同样适用于注释:它们可以出现在任何地方,我们不想在语法的每一部分中专门处理它们,所以我们只是忽略它们(至少在解析时)。

7. 创建语法

现在我们已经了解了规则的基本语法,我们可以看看定义语法的两种不同方法:自上而下和自下而上。

自上而下的方法

这种方法包括从用您的语言编写的文件的一般组织开始。

文件的主要部分是什么?他们的命令是什么?每个部分包含什么?

例如,一个 Java 文件可以分为三个部分:

  • 包裹声明
  • 进口
  • 类型定义

当您已经知道要为其设计语法的语言或格式时,这种方法最有效。这可能是具有良好理论背景的人或喜欢从“大计划”开始的人喜欢的策略。

使用这种方法时,您首先要定义代表整个文件的规则。它可能会包括其他规则,以代表主要部分。然后定义这些规则,从最一般的抽象规则转移到低级的实用规则。

自下而上的方法

自下而上的方法首先关注小元素:定义如何捕获标记,如何定义基本表达式等。然后我们转向更高级别的构造,直到我们定义了代表整个文件的规则。

我个人更喜欢从底部开始,即用词法分析器分析的基本项目。然后你从那里自然地发展到结构,这是由解析器处理的。这种方法允许专注于一小部分语法,为此构建测试,确保它按预期工作,然后继续下一点。

这种方法模仿我们学习的方式。此外,从许多语言中实际上很常见的真实代码开始还有一个好处。事实上,大多数语言都有标识符、注释、空格等。显然您可能需要调整一些东西,例如 HTML 中的注释在功能上与 C# 中的注释相同,但它们具有不同的分隔符。

自下而上方法的缺点在于解析器是您真正关心的事情。你没有被要求构建一个词法分析器,你被要求构建一个可以提供特定功能的解析器。因此,从最后一部分开始,词法分析器,如果您还不知道程序的其余部分将如何工作,您可能最终会进行一些重构。

8. 设计数据格式

为一种新语言设计语法是很困难的。您必须创建一种对用户来说简单直观的语言,同时又要明确地使语法易于管理。它必须简洁、清晰、自然,并且不应该妨碍用户。

所以我们从一些有限的东西开始:一个简单聊天程序的语法。

让我们从更好地描述我们的目标开始:

  • 不会有段落,因此我们可以使用换行符作为消息之间的分隔符
  • 我们希望允许表情符号、提及和链接。我们不支持 HTML 标签
  • 由于我们的聊天对象是烦人的青少年,我们希望让用户能够轻松地大喊大叫并设置文本颜色的格式。

终于,青少年们可以大喊大叫了,而且都是粉红色的。多么美好的时光啊。

9.词法分析器规则

我们从为我们的聊天语言定义词法分析器规则开始。请记住,词法分析器规则实际上位于文件的末尾。

/*
 * Lexer Rules
 */
 
fragment A          : ('A'|'a') ;
fragment S          : ('S'|'s') ;
fragment Y          : ('Y'|'y') ;
fragment H          : ('H'|'h') ;
fragment O          : ('O'|'o') ;
fragment U          : ('U'|'u') ;
fragment T          : ('T'|'t') ;
 
fragment LOWERCASE  : [a-z] ;
fragment UPPERCASE  : [A-Z] ;
 
SAYS                : S A Y S ;
 
SHOUTS              : S H O U T S;
 
WORD                : (LOWERCASE | UPPERCASE | '_')+ ;
 
WHITESPACE          : (' ' | '\t') ;
 
NEWLINE             : ('\r'? '\n' | '\r')+ ;
 
TEXT                : ~[\])]+ ;

在此示例中,我们使用规则片段:它们是词法分析器规则的可重用构建块。您定义它们,然后在词法分析器规则中引用它们。如果您定义了它们但不将它们包含在词法分析器规则中,它们就没有任何效果。

我们为要在关键字中使用的字母定义一个片段。这是为什么?因为我们要支持不区分大小写的关键字。除了避免重复字符大小写外,它们还用于处理浮点数。为避免在点/逗号前后重复数字。比如下面的例子。

fragment DIGIT : [0-9] ;
NUMBER         : DIGIT+ ([.,] DIGIT+)? ;

TEXT 标记显示了如何捕获所有内容,波浪号 (‘~’) 后面的字符除外。我们排除了右方括号“]”,但由于它是用于标识一组字符结尾的字符,因此我们必须通过在其前面加上反斜杠“\”来转义它。

换行规则是这样制定的,因为操作系统实际上有不同的方式指示换行符,有些包括 carriage return ('\r') 其他的 newline ('\n') 字符,或两者的组合.

10.解析器规则

我们继续解析器规则,这是我们的程序将最直接交互的规则。

/*
 * Parser Rules
 */
 
chat                : line+ EOF ;
 
line                : name command message NEWLINE;
 
message             : (emoticon | link | color | mention | WORD | WHITESPACE)+ ;
 
name                : WORD ;
 
command             : (SAYS | SHOUTS) ':' WHITESPACE ;
                                        
emoticon            : ':' '-'? ')'
                    | ':' '-'? '('
                    ;
 
link                : '[' TEXT ']' '(' TEXT ')' ;
 
color               : '/' WORD '/' message '/';
 
mention             : '@' WORD ;

第一个有趣的部分是消息,与其说它包含什么,不如说它代表的结构。我们说 message 可以是任何顺序列出的规则中的任何内容。这是一种简单的方法,可以解决处理空白的问题,而不必每次都重复。因为作为用户,我们发现空格无关紧要,所以我们看到了类似 WORD WORD mention 的东西,但解析器实际上看到的是 WORD WHITESPACE WORD WHITESPACE mention WHITESPACE

另一种处理空白的方法,当你无法摆脱它时,是更高级的:词法模式。基本上它允许您指定两个词法分析器部分:一个用于结构化部分,另一个用于简单文本。这对于解析 XML 或 HTML 之类的东西很有用。我们稍后会展示它。

command规则很明显,你只需要注意command的两个选项和冒号之间不能有空格,但后面需要一个WHITESPACE 表情规则显示了另一种表示多项选择的符号,您可以使用不带括号的竖线字符“|”。我们只支持两种表情符号,快乐和悲伤,有或没有中间线。

link 规则可能被认为是错误或实施不当,正如我们已经说过的,事实上,TEXT 捕获除了某些特殊字符之外的所有内容。您可能只想在括号内允许 WORDWHITESPACE,或者在方括号内强制使用正确的链接格式。另一方面,这允许用户在编写链接时犯错误而不会使解析器抱怨。

你必须记住解析器不能检查语义

例如,它不知道指示颜色的 WORD 是否实际上代表了有效颜色。也就是说,它不知道用“dog”是错的,但用“red”是对的。这必须由程序逻辑检查,该程序可以访问可用的颜色。您必须找到在语法和您自己的代码之间划分执行的正确平衡。

解析器应该只检查语法。所以经验法则是,当有疑问时,让解析器将内容传递给您的程序。然后,在您的程序中,您检查语义并确保该规则实际上具有正确的含义。

让我们看一下规则color:,它可以包含一个message,它本身可以是message的一部分;这种歧义将通过使用的上下文。

11.错误与调整

在尝试我们的新语法之前,我们必须在文件的开头为它添加一个名称。名称必须与文件相同,文件应具有 .g4 扩展名。

grammar Chat;

您可以在官方文档 中找到如何为您的平台安装所有内容。一切安装完成后,我们创建语法,编译生成 Java 代码,然后运行测试工具。

// lines preceded by $ are commands
// > are input to the tool
// - are output from the tool
$ antlr4 Chat.g4
$ javac Chat*.java
// grun is the testing tool, Chat is the name of the grammar, chat the rule that we want to parse
$ grun Chat chat
> john SAYS: hello @michael this will not work
// CTRL+D on Linux, CTRL+Z on Windows
> CTRL+D/CTRL+Z
- line 1:0 mismatched input 'john SAYS: hello @michael this will not work\n' expecting WORD

好的,它不起作用。为什么它期待 WORD?它就在那里!让我们试着找出答案,使用选项 -tokens 让它显示它识别的标记。

$ grun Chat chat -tokens
> john SAYS: hello @michael this will not work
- [@0,0:44='john SAYS: hello @michael this will not work\n',<TEXT>,1:0]
- [@1,45:44='<EOF>',<EOF>,2:0]

所以它只能看到 TEXT 标记。但是我们把它放在语法的末尾,会发生什么?问题是它总是尝试匹配最大可能的标记。所有这些文本都是有效的 TEXT 标记。我们如何解决这个问题?有很多方法,第一种当然是摆脱那个令牌。但现在我们将看到第二个最简单的。

[..]
 
link                : TEXT TEXT ;
 
[..]
 
TEXT                : ('['|'(') ~[\])]+ (']'|')');

我们更改了有问题的标记,使其包含前面的括号或方括号。请注意,这并不完全相同,因为它允许两个系列的括号或方括号。但这是第一步,毕竟我们正在这里学习。

让我们检查它是否有效:

$ grun Chat chat -tokens
> john SAYS: hello @michael this will not work
- [@0,0:3='john',<WORD>,1:0]
- [@1,4:4=' ',<WHITESPACE>,1:4]
- [@2,5:8='SAYS',<SAYS>,1:5]
- [@3,9:9=':',<':'>,1:9]
- [@4,10:10=' ',<WHITESPACE>,1:10]
- [@5,11:15='hello',<WORD>,1:11]
- [@6,16:16=' ',<WHITESPACE>,1:16]
- [@7,17:17='@',<'@'>,1:17]
- [@8,18:24='michael',<WORD>,1:18]
- [@9,25:25=' ',<WHITESPACE>,1:25]
- [@10,26:29='this',<WORD>,1:26]
- [@11,30:30=' ',<WHITESPACE>,1:30]
- [@12,31:34='will',<WORD>,1:31]
- [@13,35:35=' ',<WHITESPACE>,1:35]
- [@14,36:38='not',<WORD>,1:36]
- [@15,39:39=' ',<WHITESPACE>,1:39]
- [@16,40:43='work',<WORD>,1:40]
- [@17,44:44='\n',<NEWLINE>,1:44]
- [@18,45:44='<EOF>',<EOF>,2:0]

使用选项 -gui 我们还可以获得一个漂亮且更容易理解的图形表示。

半空中的点代表空白。

这行得通,但不是很聪明,也不是很好,也不是很有条理。不过别担心,稍后我们会看到更好的方法。该解决方案的一个积极方面是它允许显示另一个技巧。

TEXT                : ('['|'(') .*? (']'|')');

这是令牌 TEXT 的等效表述:'.' 匹配任何字符,'*' 表示前面的匹配可以随时重复,'?' 表示前面的匹配是非-贪婪的。也就是说,前面的子规则匹配除了它后面的所有内容,允许匹配右括号或方括号。

中级

在本节中,我们将了解如何在您的程序中使用 ANTLR、您需要使用的库和函数、如何测试您的解析器等。我们将了解什么是侦听器以及如何使用侦听器。我们还通过查看更高级的概念(例如语义谓词)来建立我们的基础知识。虽然我们的项目主要使用 Javascript 和 Python,但这个概念通常适用于所有语言。当您需要记住如何组织您的项目时,您可以返回本节。

12. 使用 Javascript 设置聊天项目

在前面的部分中,我们已经了解了如何逐步为聊天程序构建语法。现在让我们将刚刚创建的语法复制到 Javascript 文件的同一文件夹中。

grammar Chat;
 
/*
 * Parser Rules
 */
 
chat                : line+ EOF ;
 
line                : name command message NEWLINE ;
 
message             : (emoticon | link | color | mention | WORD | WHITESPACE)+ ;
 
name                : WORD WHITESPACE;
 
command             : (SAYS | SHOUTS) ':' WHITESPACE ;
                                        
emoticon            : ':' '-'? ')'
                    | ':' '-'? '('
                    ;
 
link                : TEXT TEXT ;
 
color               : '/' WORD '/' message '/';
 
mention             : '@' WORD ;
 
 
/*
 * Lexer Rules
 */
 
fragment A          : ('A'|'a') ;
fragment S          : ('S'|'s') ;
fragment Y          : ('Y'|'y') ;
fragment H          : ('H'|'h') ;
fragment O          : ('O'|'o') ;
fragment U          : ('U'|'u') ;
fragment T          : ('T'|'t') ;
 
fragment LOWERCASE  : [a-z] ;
fragment UPPERCASE  : [A-Z] ;
 
SAYS                : S A Y S ;
 
SHOUTS              : S H O U T S ;
 
WORD                : (LOWERCASE | UPPERCASE | '_')+ ;
 
WHITESPACE          : (' ' | '\t')+ ;
 
NEWLINE             : ('\r'? '\n' | '\r')+ ;
 
TEXT                : ('['|'(') ~[\])]+ (']'|')');

我们可以简单地通过使用 ANTLR4 Java 程序指定正确的选项来创建相应的 Javascript 解析器。

antlr4 -Dlanguage=JavaScript Chat.g4

现在你会在文件夹中发现一些新文件,名称如 ChatLexer.js, ChatParser.js 还有 *.tokens 文件,这些文件都不包含我们感兴趣的东西,除非您想了解 ANTLR 的内部工作原理。

您要查看的文件是 ChatListener.js,您不会修改其中的任何内容,但它包含我们将用自己的侦听器覆盖的方法和函数。我们不打算修改它,因为每次重新生成语法时更改都会被覆盖。

查看它,您可以看到几个进入/退出函数,一对用于我们的每个解析器规则。当遇到符合规则的一段代码时,将调用这些函数。这是侦听器的默认实现,它允许您在派生侦听器上仅覆盖所需的功能,而将其余部分保留。

var antlr4 = require('antlr4/index');
 
// This class defines a complete listener for a parse tree produced by ChatParser.
function ChatListener() {
    antlr4.tree.ParseTreeListener.call(this);
    return this;
}
 
ChatListener.prototype = Object.create(antlr4.tree.ParseTreeListener.prototype);
ChatListener.prototype.constructor = ChatListener;
 
// Enter a parse tree produced by ChatParser#chat.
ChatListener.prototype.enterChat = function(ctx) {
};
 
// Exit a parse tree produced by ChatParser#chat.
ChatListener.prototype.exitChat = function(ctx) {
};
 
[..]

创建 Listener 的替代方法是创建 Visitor。主要区别在于您既不能控制侦听器的流程,也不能从其函数返回任何内容,而您可以对访问者执行这两项操作。因此,如果您需要控制 AST 节点的输入方式,或者从其中的几个节点收集信息,您可能需要使用访问者。例如,这对于代码生成很有用,其中创建新源代码所需的一些信息分布在许多部分。听众和访问者都使用深度优先搜索。

深度优先搜索意味着当一个节点将被访问时,它的子节点将被访问,如果一个子节点有自己的子节点,则在继续访问第一个节点的其他子节点之前将访问它们。下图将使这个概念更容易理解。

因此,对于侦听器,将在第一次遇到该节点时触发 enter 事件,并在退出其所有子节点后触发 exit 事件。在下图中,您可以看到当侦听器遇到 line 节点时将触发哪些函数的示例(为简单起见,仅显示与 line 相关的函数)。

对于标准访问者,行为将是类似的,当然,每个节点只会触发一个访问事件。在下图中,您可以看到当访问者遇到 line 节点时将触发什么函数的示例(为简单起见,仅显示与 line 相关的函数)。

请记住,这对于访问者的默认实现是正确的,它是通过在每个函数中返回每个节点的子节点来完成的。如果您覆盖访问者的方法,您有责任让它继续旅程或就此停止。

13. 蚂蚁.js

终于可以看看典型的 ANTLR 程序是什么样子了。

const http = require('http');
const antlr4 = require('antlr4/index');
const ChatLexer = require('./ChatLexer');
const ChatParser = require('./ChatParser');
const HtmlChatListener = require('./HtmlChatListener').HtmlChatListener;
 
http.createServer((req, res) => {
   
   res.writeHead(200, {
       'Content-Type': 'text/html',        
   });
 
   res.write('<html><head><meta charset="UTF-8"/></head><body>');
   
   var input = "john SHOUTS: hello @michael /pink/this will work/ :-) \n";
   var chars = new antlr4.InputStream(input);
   var lexer = new ChatLexer.ChatLexer(chars);
   var tokens  = new antlr4.CommonTokenStream(lexer);
   var parser = new ChatParser.ChatParser(tokens);
   parser.buildParseTrees = true;   
   var tree = parser.chat();   
   var htmlChat = new HtmlChatListener(res);
   antlr4.tree.ParseTreeWalker.DEFAULT.walk(htmlChat, tree);
   
   res.write('</body></html>');
   res.end();
 
}).listen(1337);

在主文件的开头,我们导入(使用 require)必要的库和文件、antlr4(运行时)和我们生成的解析器,以及我们要访问的侦听器稍后再看。

为简单起见,我们从字符串中获取输入,而在真实场景中,它将来自编辑器。

第 16-19 行展示了每个 ANTLR 程序的基础:您从输入中创建字符流,将其提供给词法分析器并将它们转换为标记,然后由解析器解释。

花点时间思考一下这一点很有用:词法分析器处理输入的字符,准确地说是输入的副本,而解析器处理解析器生成的标记。 词法分析器不直接处理输入,解析器甚至看不到字符

记住这一点很重要,以防你需要做一些高级的事情,比如操作输入。在这种情况下,输入是一个字符串,但当然,它可以是任何内容流。

第 20 行是多余的,因为该选项已经默认为 true,但这可能会在运行时的未来版本中发生变化,因此您最好指定它。

然后,在第 21 行,我们将树的根节点设置为 chat 规则。您想要调用解析器指定通常是第一条规则的规则。然而,您实际上可以直接调用任何规则,例如 color

一旦我们从解析器获得 AST,通常我们希望使用侦听器或访问器来处理它。在这种情况下,我们指定一个侦听器。我们的特定侦听器采用一个参数:响应对象。我们想用它在响应中放置一些文本以发送给用户。设置好监听器后,我们终于和我们的监听器一起遍历了这棵树。

14.HtmlChatListener.js

我们继续查看我们的Chat 项目的侦听器。

const antlr4 = require('antlr4/index');
const ChatLexer = require('./ChatLexer');
const ChatParser = require('./ChatParser');
var ChatListener = require('./ChatListener').ChatListener;
 
HtmlChatListener = function(res) {
    this.Res = res;    
    ChatListener.call(this); // inherit default listener
    return this;
};
 
// inherit default listener
HtmlChatListener.prototype = Object.create(ChatListener.prototype);
HtmlChatListener.prototype.constructor = HtmlChatListener;
 
// override default listener behavior
HtmlChatListener.prototype.enterName = function(ctx) {          
    this.Res.write("<strong>");    
};
 
HtmlChatListener.prototype.exitName = function(ctx) {      
    this.Res.write(ctx.WORD().getText());
    this.Res.write("</strong> ");
}; 
 
HtmlChatListener.prototype.exitEmoticon = function(ctx) {      
    var emoticon = ctx.getText();        
    
    if(emoticon == ':-)' || emoticon == ':)')
    {
        this.Res.write("??");        
    }
    
    if(emoticon == ':-(' || emoticon == ':(')
    {
        this.Res.write("??");            
    }
}; 
 
HtmlChatListener.prototype.enterCommand = function(ctx) {          
    if(ctx.SAYS() != null)
        this.Res.write(ctx.SAYS().getText() + ':' + '<p>');
 
    if(ctx.SHOUTS() != null)
        this.Res.write(ctx.SHOUTS().getText() + ':' + '<p style="text-transform: uppercase">');
};
 
HtmlChatListener.prototype.exitLine = function(ctx) {              
    this.Res.write("</p>");
};
 
exports.HtmlChatListener = HtmlChatListener;

在 requires 函数调用之后,我们使我们的 HtmlChatListener 扩展 ChatListener。 有趣的东西从第 17 行开始。

ctx 参数是我们正在进入/退出的节点的特定类上下文的实例。所以 enterNameNameContextexitEmoticonEmoticonContext,等等。这个特定的上下文将有适当的元素规则,这将使轻松访问相应的令牌和子规则成为可能。例如,NameContext 将包含 WORD()WHITESPACE(); CommandContext 将包含 < strong>WHITESPACE()、SAYS()SHOUTS()。

这些函数,enter*exit*, 在遍历表示程序换行符的 AST 时,每次进入或退出相应的节点时都会被 walker 调用。侦听器允许您执行一些代码,但重要的是要记住您不能停止 walker 的执行和函数的执行

在第 18 行,我们首先打印一个 strong 标签,因为我们希望名称为粗体,然后在 exitName 上,我们从标记 WORD 中获取文本并关闭标签。请注意,我们忽略了 WHITESPACE 标记,没有说明我们必须显示所有内容。在这种情况下,我们可以在 enter 或 exit 函数上完成所有操作。

在函数 exitEmoticon 中,我们简单地将表情符号文本转换为表情符号字符。我们得到了整个规则的文本,因为没有为这个解析器规则定义的标记。在 enterCommand 上,可能有两个标记中的任何一个 SAYSSHOUTS,因此我们检查定义了哪一个。然后我们更改以下文本,如果它是 SHOUT,则将其转换为大写。 请注意,我们在 line 规则的出口处关闭了 p 标记,因为从语义上讲,该命令会更改消息的所有文本。

我们现在要做的就是使用 nodejs antlr.js 启动节点,并将浏览器指向它的地址,通常是 http://localhost:1337/,我们将看到下图。

所以一切都很好,我们只需要添加所有不同的侦听器来处理语言的其余部分。让我们从颜色消息开始。

15. 与倾听者一起工作

我们已经看到了如何开始定义一个监听器。现在让我们认真看看如何在一个完整的、健壮的监听器中进化。让我们从添加对颜色 的支持开始,并检查我们辛勤工作的结果。

HtmlChatListener.prototype.enterColor = function(ctx) {     
    var color = ctx.WORD().getText();         
    this.Res.write('<span style="color: ' + color + '">');        
};
 
HtmlChatListener.prototype.exitColor = function(ctx) {          
    this.Res.write("</span>");    
}; 
 
HtmlChatListener.prototype.exitMessage = function(ctx) {             
    this.Res.write(ctx.getText());
};
 
exports.HtmlChatListener = HtmlChatListener;

  

除了它不起作用。或者它可能太管用了:我们将 message 的某些部分写了两次(“这会起作用”):首先当我们检查特定节点时,message 的子节点,以及然后在最后。

幸运的是,我们可以使用 Javascript 动态更改对象,因此我们可以利用这一事实来更改 *Context 对象本身。

HtmlChatListener.prototype.exitColor = function(ctx) {         
    ctx.text += ctx.message().text;    
    ctx.text += '</span>';
};
 
HtmlChatListener.prototype.exitEmoticon = function(ctx) {      
    var emoticon = ctx.getText();        
    
    if(emoticon == ':-)' || emoticon == ':)')
    {        
        ctx.text = "??";
    }
    
    if(emoticon == ':-(' || emoticon == ':(')
    {          
        ctx.text = "??";
    }
}; 
 
HtmlChatListener.prototype.exitMessage = function(ctx) {                
    var text = '';
 
    for (var index = 0; index <  ctx.children.length; index++ ) {
        if(ctx.children[index].text != null)
            text += ctx.children[index].text;
        else
            text += ctx.children[index].getText();
    }
 
    if(ctx.parentCtx instanceof ChatParser.ChatParser.LineContext == false)
    {
        ctx.text = text;        
    }
    else
    {
        this.Res.write(text);
        this.Res.write("</p>");
    }
};

上面的代码片段中只显示了修改的部分。我们向每个转换其文本的节点添加一个 text 字段,然后在每个 message 的出口处打印文本,如果它是主要消息,即直接line 规则的子项。如果它是一条消息,它也是颜色的孩子,我们将 text 字段添加到我们正在退出的节点,并让 color 打印它。我们在第 30 行检查它,我们在其中查看父节点以查看它是否是对象 LineContext 的实例。这也是每个 ctx 参数如何对应于正确类型的进一步证据。

在第 23 行和第 27 行之间,我们可以看到生成树的每个节点的另一个字段:children,显然它包含子节点。您可以观察到,如果字段 text 存在,我们将其添加到适当的变量中,否则我们使用通常的函数来获取节点的文本。

16. 用语义谓词解决歧义

到目前为止,我们已经了解了如何使用 Javascript 为聊天语言构建解析器。让我们继续研究这个语法,但切换到 python。请记住,所有代码都在 repository 中可用。在此之前,我们必须解决一个烦人的问题:TEXT 令牌。我们的解决方案很糟糕,而且,如果我们试图获取令牌的文本,我们将不得不修剪边缘、圆括号或方括号。所以,我们能做些什么?

我们可以使用 ANTLR 的一个特殊功能,称为语义谓词。顾名思义,它们是产生布尔值的表达式。他们有选择地启用或禁用以下规则,从而允许解决歧义。可以使用它们的另一个原因是支持同一语言的不同版本,例如具有新构造的版本或没有它的旧版本。

从技术上讲,它们是更大的 actions 组的一部分,允许将任意代码嵌入到语法中。 缺点是语法不再独立于语言,因为操作中的代码必须对目标语言有效。出于这个原因,通常只使用语义谓词被认为是一个好主意,当它们无法避免时,并将大部分代码留给访问者/听众。

link                : '[' TEXT ']' '(' TEXT ')';
 
TEXT                : {self._input.LA(-1) == ord('[') or self._input.LA(-1) == ord('(')}? ~[\])]+ ;

我们将 link 恢复为其原始形式,但我们向 TEXT 标记添加了一个语义谓词,写在大括号内,后面跟着一个问号。我们使用 self._input.LA(-1) 来检查当前字符之前的字符,如果这个字符是方括号或左括号,我们激活 TEXT 标记。重要的是要重复这必须是我们目标语言中的有效代码,它将在生成的 Lexer 或 Parser 中结束,在我们的例子中是 ChatLexer.py.

这不仅对语法本身很重要,还因为不同的目标可能有不同的字段或方法,例如 LA 在 python 中返回一个 int,所以我们必须转换charint

让我们看看其他语言的等效形式。

// C#. Notice that is .La and not .LA
TEXT : {_input.La(-1) == '[' || _input.La(-1) == '('}? ~[\])]+ ;
// Java
TEXT : {_input.LA(-1) == '[' || _input.LA(-1) == '('}? ~[\])]+ ;
// Javascript
TEXT : {this._input.LA(-1) == '[' || this._input.LA(-1) == '('}? ~[\])]+ ;

如果你想测试前面的标记,你可以使用 _input.LT(-1,) 但你只能对解析器规则这样做。例如,如果您只想在 WHITESPACE 标记之前启用 mention 规则。

// C#
mention: {_input.Lt(-1).Type == WHITESPACE}? '@' WORD ;
// Java
mention: {_input.LT(1).getType() == WHITESPACE}? '@' WORD ;
// Python
mention: {self._input.LT(-1).text == ' '}? '@' WORD ;
// Javascript
mention: {this._input.LT(1).text == ' '}? '@' WORD ;

17. 在 Python 中继续聊天

在查看 Python 示例之前,我们必须修改语法并将 TEXT 标记放在 WORD 标记之前。否则 ANTLR 可能会分配不正确的标记,在括号或括号之间的字符对 WORD 都有效的情况下,例如如果它在哪里 [this](link)

在 python 中使用 ANTLR 并不比在任何其他平台上更难,你只需要注意 Python 的版本,2 或 3。

antlr4 -Dlanguage=Python3 Chat.g4

就是这样。因此,当您运行该命令后,在您的 python 项目目录中,将有一个新生成的解析器和一个词法分析器。您可能会发现查看 ChatLexer.py 很有趣,尤其是函数 TEXT_sempred (sempred 代表 semantic predicate ).

def TEXT_sempred(self, localctx:RuleContext, predIndex:int):
    if predIndex == 0:
        return self._input.LA(-1) == ord('[') or self._input.LA(-1) == ord('(')

您可以在代码中看到我们的谓词。这也意味着您必须检查谓词中使用的函数的正确库是否可供词法分析器使用。

18. 使用监听器的 Python 方式

Python 项目的主文件与 Javascript 项目非常相似,mutatis mutandis 当然。也就是说,我们必须使库和函数适应不同语言的正确版本。

import sys
from antlr4 import *
from ChatLexer import ChatLexer
from ChatParser import ChatParser
from HtmlChatListener import HtmlChatListener
 
def main(argv):
    input = FileStream(argv[1])
    lexer = ChatLexer(input)
    stream = CommonTokenStream(lexer)
    parser = ChatParser(stream)
    tree = parser.chat()
 
    output = open("output.html","w")
    
    htmlChat = HtmlChatListener(output)
    walker = ParseTreeWalker()
    walker.walk(htmlChat, tree)
        
    output.close()      
 
if __name__ == '__main__':
    main(sys.argv)

我们还将输入和输出更改为文件,这避免了需要在 Python 中启动服务器或使用终端不支持的字符的问题。

import sys
from antlr4 import *
from ChatParser import ChatParser
from ChatListener import ChatListener
 
class HtmlChatListener(ChatListener) :
    def __init__(self, output):
        self.output = output
        self.output.write('<html><head><meta charset="UTF-8"/></head><body>')
 
    def enterName(self, ctx:ChatParser.NameContext) :
        self.output.write("<strong>") 
 
    def exitName(self, ctx:ChatParser.NameContext) :
        self.output.write(ctx.WORD().getText()) 
        self.output.write("</strong> ") 
 
    def enterColor(self, ctx:ChatParser.ColorContext) :
        color = ctx.WORD().getText()
        ctx.text = '<span style="color: ' + color + '">'        
 
    def exitColor(self, ctx:ChatParser.ColorContext):         
        ctx.text += ctx.message().text
        ctx.text += '</span>'
 
    def exitEmoticon(self, ctx:ChatParser.EmoticonContext) : 
        emoticon = ctx.getText()
 
        if emoticon == ':-)' or emoticon == ':)' :
            ctx.text = "??"
    
        if emoticon == ':-(' or emoticon == ':(' :
            ctx.text = "??"
 
    def enterLink(self, ctx:ChatParser.LinkContext):
        ctx.text = '<a href="%s">%s</a>' % (ctx.TEXT()[1], (ctx.TEXT()[0]))
 
    def exitMessage(self, ctx:ChatParser.MessageContext):
        text = ''
 
        for child in ctx.children:
            if hasattr(child, 'text'):
                text += child.text
            else:
                text += child.getText()
        
        if isinstance(ctx.parentCtx, ChatParser.LineContext) is False:
            ctx.text = text
        else:    
            self.output.write(text)
            self.output.write("</p>") 
 
    def enterCommand(self, ctx:ChatParser.CommandContext):
        if ctx.SAYS() is not None :
            self.output.write(ctx.SAYS().getText() + ':' + '<p>')
 
        if ctx.SHOUTS() is not None :
            self.output.write(ctx.SHOUTS().getText() + ':' + '<p style="text-transform: uppercase">')    
 
    def exitChat(self, ctx:ChatParser.ChatContext):
        self.output.write("</body></html>")

除了第 35-36 行,我们介绍了对链接的支持,没有什么新内容。虽然您可能会注意到 Python 语法更清晰,并且在具有动态类型的同时,它不像 Javascript 那样松散地类型化。明确写出不同类型的 *Context 对象。如果 Python 工具能像语言本身一样易于使用就好了。但是我们当然不能就这么飞过python,所以我们也引入了测试。

19. 用 Python 测试

虽然 Visual Studio Code 有一个非常好的 Python 扩展,它也支持单元测试,但为了兼容性,我们将使用命令行。

python3 -m unittest discover -s . -p ChatTests.py

这就是您运行测试的方式,但在此之前我们必须编写它们。实际上,甚至在此之前,我们必须编写一个 ErrorListener 来管理我们可以找到的错误。虽然我们可以简单地读取默认错误侦听器输出的文本,但使用我们自己的实现有一个优势,即我们可以更轻松地控制发生的事情。

import sys
from antlr4 import *
from ChatParser import ChatParser
from ChatListener import ChatListener
from antlr4.error.ErrorListener import *
import io
 
class ChatErrorListener(ErrorListener):
 
    def __init__(self, output):
        self.output = output        
        self._symbol = ''
    
    def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):        
        self.output.write(msg)
        self._symbol = offendingSymbol.text
 
    @property        
    def symbol(self):
        return self._symbol

我们的类派生自 ErrorListener,我们只需实现 syntaxError。尽管我们还添加了一个属性 symbol 来轻松检查哪个符号可能导致了错误。

from antlr4 import *
from ChatLexer import ChatLexer
from ChatParser import ChatParser
from HtmlChatListener import HtmlChatListener
from ChatErrorListener import ChatErrorListener
import unittest
import io
 
class TestChatParser(unittest.TestCase):
 
    def setup(self, text):        
        lexer = ChatLexer(InputStream(text))        
        stream = CommonTokenStream(lexer)
        parser = ChatParser(stream)
        
        self.output = io.StringIO()
        self.error = io.StringIO()
 
        parser.removeErrorListeners()        
        errorListener = ChatErrorListener(self.error)
        parser.addErrorListener(errorListener)  
 
        self.errorListener = errorListener              
        
        return parser
        
    def test_valid_name(self):
        parser = self.setup("John ")
        tree = parser.name()               
    
        htmlChat = HtmlChatListener(self.output)
        walker = ParseTreeWalker()
        walker.walk(htmlChat, tree)              
 
        # let's check that there aren't any symbols in errorListener         
        self.assertEqual(len(self.errorListener.symbol), 0)
 
    def test_invalid_name(self):
        parser = self.setup("Joh-")
        tree = parser.name()               
    
        htmlChat = HtmlChatListener(self.output)
        walker = ParseTreeWalker()
        walker.walk(htmlChat, tree)              
 
        # let's check the symbol in errorListener
        self.assertEqual(self.errorListener.symbol, '-')
 
if __name__ == '__main__':
    unittest.main()

setup 方法用于确保一切设置正确;在第 19-21 行,我们还设置了 ChatErrorListener,但首先我们删除了默认值,否则它仍会在标准输出上输出错误。我们正在侦听解析器中的错误,但我们也可以捕获词法分析器生成的错误。这取决于你想测试什么。您可能需要检查两者。

两种正确的测试方法检查有效名称和无效名称。检查链接到我们之前定义的属性 symbol,如果它为空则一切正常,否则它包含导致错误的符号。请注意,在第 28 行,字符串末尾有一个空格,因为我们已将规则 name 定义为以 WHITESPACE 标记结尾。

20. 解析标记

ANTLR 可以解析很多东西,包括二进制数据,在这种情况下,标记由不可打印的字符组成。但更常见的问题是解析标记语言,如 XML 或 HTML。标记也是一种可用于您自己的创作的有用格式,因为它允许将非结构化文本内容与结构化注释混合。它们从根本上代表了一种智能文档形式,包含文本和结构化数据。描述它们的技术术语是岛屿语言。这种类型不限于只包含标记,有时它是一个视角问题。

例如,您可能必须构建一个忽略预处理器指令的解析器。在那种情况下,您必须找到一种方法来区分正确的代码和指令,指令遵循不同的规则。

无论如何,解析此类语言的问题在于,有很多文本我们实际上不必解析,但我们不能忽略或丢弃,因为文本包含对用户有用的信息,它是结构部分的文件。解决方案是词法模式,这是一种在更大的自由文本海洋中解析结构化内容的方法。

21. 词汇模式

我们将从一个新语法开始,看看如何使用词法模式。

lexer grammar MarkupLexer;
 
OPEN                : '[' -> pushMode(BBCODE) ;
TEXT                : ~('[')+ ;
 
// Parsing content inside tags
mode BBCODE;
 
CLOSE               : ']' -> popMode ;
SLASH               : '/' ;
EQUALS              : '=' ;
STRING              : '"' .*? '"' ;
ID                  : LETTERS+ ;
WS                  : [ \t\r\n] -> skip ;
 
fragment LETTERS    : [a-zA-Z] ;

查看第一行,您会注意到一个区别:我们正在定义一个 lexer grammar,而不是通常的 (combined) grammar您根本无法定义词法模式和解析器语法。您只能在词法分析器语法中使用词法模式,而不能在组合语法中使用。其余的并不奇怪,如您所见,我们正在定义一种 BBCode 标记,标签由方括号分隔。

在第 3、7 和 9 行,你会发现基本上所有你需要知道的关于词法模式的信息。您可以定义一个或多个可以分隔不同模式并激活它们的标记。

默认模式已经被隐式定义,如果您需要定义您的模式,您只需使用 mode 后跟一个名称。除了标记语言,词法模式通常用于处理字符串插值。当字符串文字可以包含的不仅仅是简单的文本,还可以包含任意表达式之类的东西。

当我们使用组合语法时,我们可以隐式定义标记:当我们在解析器规则中使用像“=”这样的字符串时,我们就是这样做的。现在我们正在使用单独的词法分析器和解析器语法,我们不能这样做。这意味着必须明确定义每个标记。所以我们有像 SLASH 或 EQUALS 这样的定义,它们通常可以直接在解析器规则中使用。这个概念很简单:在词法分析器语法中,我们需要定义所有标记,因为它们不能稍后在解析器语法中定义。

22. 语法分析器

可以这么说,我们看的是词法分析器语法的另一面。

parser grammar MarkupParser;
 
options { tokenVocab=MarkupLexer; }
 
file        : element* ;
 
attribute   : ID '=' STRING ;
 
content     : TEXT ;
 
element     : (content | tag) ;
 
tag         : '[' ID attribute? ']' element* '[' '/' ID ']' ;

在第一行我们定义了一个parser grammar。由于我们需要的标记是在词法分析器语法中定义的,因此我们需要使用一个选项告诉 ANTLR 在哪里可以找到它们。这在组合语法中不是必需的,因为标记是在同一个文件中定义的。

文档 中还有许多其他可用选项。

除了我们定义一个content 规则以便我们可以更轻松地管理稍后在程序中找到的文本之外,几乎没有其他要添加的内容。

我只想说,如您所见,我们不需要每次都显式使用标记(例如 SLASH),而是可以使用相应的文本(例如“/”)。

ANTLR 将自动转换相应标记中的文本,但这只有在它们已经定义时才会发生。简而言之,就好像我们写了:

tag : OPEN ID attribute? CLOSE element* OPEN SLASH ID CLOSE ;

但是如果我们还没有在词法分析器语法中明确定义它们,我们就不可能使用隐式方式。另一种看待这个问题的方法是:当我们定义一个组合语法时,ANTLR 定义了使用所有的标记,我们没有明确定义自己。当我们需要使用单独的词法分析器和解析器语法时,我们必须自己明确定义每个标记。一旦我们这样做了,我们就可以以我们想要的任何方式使用它们。

在转到实际的 Java 代码之前,让我们先看看 AST 的示例输入。

您可以很容易地注意到 element 规则有点透明:在您希望找到它的地方总会有一个 tagcontent。那么我们为什么要定义它呢?有两个优点:避免语法重复和简化解析结果的管理。我们避免重复,因为如果我们没有元素规则,我们应该在所有使用它的地方重复 (content|tag)。如果有一天我们添加一种新的元素会怎样?除此之外,它还简化了 AST 的处理,因为它使节点表示标签和内容都扩展了注释祖先。

先进的

本节我们加深对ANTLR的理解。我们将查看我们在解析冒险中可能必须处理的更复杂的示例和情况。我们将学习如何执行更高级的测试,以捕获更多错误并确保我们的代码具有更好的质量。我们将看到访客是什么以及如何使用它。最后,我们将看到如何处理表达式及其带来的复杂性。

当您需要处理复杂的解析问题时,您可以回到本节。

23. Java 中的标记项目

您可以按照 Java 设置 中的说明进行操作,或者只复制配套存储库的 antlr-java 文件夹。正确配置文件 pom.xml 后,这就是您构建和执行应用程序的方式。

// use mwn to generate the package
mvn package
// every time you need to execute the application
java -cp target/markup-example-1.0-jar-with-dependencies.jar me.tomassetti.examples.MarkupParser.App

如您所见,它与任何典型的 Maven 项目没有任何不同,尽管它确实比典型的 Javascript 或 Python 项目更复杂。当然,如果您使用 IDE,则无需执行与典型工作流程不同的任何操作。

24. 主App.java

我们将看到如何用 Java 编写典型的 ANTLR 应用程序。

package me.tomassetti.examples.MarkupParser;
import org.antlr.v4.runtime.*;
import org.antlr.v4.runtime.tree.*;
 
public class App 
{
    public static void main( String[] args )
    {
        ANTLRInputStream inputStream = new ANTLRInputStream(
            "I would like to [b][i]emphasize[/i][/b] this and [u]underline [b]that[/b][/u] ." +
            "Let's not forget to quote: [quote author=\"John\"]You're wrong![/quote]");
        MarkupLexer markupLexer = new MarkupLexer(inputStream);
        CommonTokenStream commonTokenStream = new CommonTokenStream(markupLexer);
        MarkupParser markupParser = new MarkupParser(commonTokenStream);
 
        MarkupParser.FileContext fileContext = markupParser.file();                
        MarkupVisitor visitor = new MarkupVisitor();                
        visitor.visit(fileContext);        
    }
}

此时主要的 java 文件应该并不奇怪,唯一新开发的是 visitor。当然,在 ANTLR 类的名称等方面存在明显的细微差异。这次我们正在构建一个访问者,其主要优势是有机会控制程序的流程。当我们仍在处理文本时,我们不想显示它,我们想将它从伪 BBCode 转换为伪 Markdown。

25. 使用 ANTLR 转换代码

处理我们从伪 BBCode 到伪 Markdown 的转换的第一个问题是设计决策。我们的两种语言是不同的,坦率地说,两种原始语言都没有设计得那么好。

BBCode 是作为一种安全预防措施而创建的,它可以禁止使用 HTML,但将其部分权力授予用户。 Markdown 被创建为一种易于阅读和编写的格式,可以翻译成 HTML。所以它们都模仿 HTML,你实际上可以在 Markdown 文档中使用 HTML。让我们开始研究真正的转换会有多混乱。

package me.tomassetti.examples.MarkupParser;
 
import org.antlr.v4.runtime.*;
import org.antlr.v4.runtime.misc.*;
import org.antlr.v4.runtime.tree.*;
 
public class MarkupVisitor extends MarkupParserBaseVisitor
{
    @Override
    public String visitFile(MarkupParser.FileContext context)
    {
         visitChildren(context);
         
         System.out.println("");
         
         return null;
    }
    
    @Override
    public String visitContent(MarkupParser.ContentContext context)
    {
        System.out.print(context.TEXT().getText());
        
        return visitChildren(context);
    }
}

我们的访问者的第一个版本打印所有文本并忽略所有标签。

您可以了解如何通过调用 visitChildren 或任何其他 visit* 函数来控制流程,并决定返回什么。我们只需要覆盖我们想要更改的方法。否则,默认实现就像 visitContent 一样,在第 23 行,它将访问子节点并允许访问者继续。就像对于侦听器一样,参数是正确的上下文类型。如果您想阻止访问者,只需像第 15 行一样返回 null。

26. 转换代码的快乐与痛苦

转换代码,即使是在非常简单的级别,也会带来一些复杂性。让我们从一些基本的访问者方法开始。

@Override
public String visitContent(MarkupParser.ContentContext context)    
{          
    return context.getText();        
}    
 
@Override
public String visitElement(MarkupParser.ElementContext context)
{
    if(context.parent instanceof MarkupParser.FileContext)
    {
        if(context.content() != null)            
            System.out.print(visitContent(context.content()));            
        if(context.tag() != null)
            System.out.print(visitTag(context.tag()));
    }    
 
    return null;
}

在看主要方法之前,让我们先看看辅助方法。首先,我们更改了 visitContent,让它返回其文本而不是打印它。其次,我们重写了 visitElement 以便它打印其子元素的文本,但前提是它是顶级元素,而不是在 标签 内。在这两种情况下,它都是通过调用适当的 visit* 方法来实现的。它知道调用哪一个,因为它会检查它是否确实具有 tagcontent 节点。

@Override
public String visitTag(MarkupParser.TagContext context)    
{
    String text = "";
    String startDelimiter = "", endDelimiter = "";
 
    String id = context.ID(0).getText();
    
    switch(id)
    {
        case "b":
            startDelimiter = endDelimiter = "**";                
        break;
        case "u":
            startDelimiter = endDelimiter = "*";                
        break;
        case "quote":
            String attribute = context.attribute().STRING().getText();
            attribute = attribute.substring(1,attribute.length()-1);
            startDelimiter = System.lineSeparator() + "> ";
            endDelimiter = System.lineSeparator() + "> " + System.lineSeparator() + "> – "
                         + attribute + System.lineSeparator();
        break;
    } 
 
    text += startDelimiter;
 
    for (MarkupParser.ElementContext node: context.element())
    {                
        if(node.tag() != null)
            text += visitTag(node.tag());
        if(node.content() != null)
            text += visitContent(node.content());                
    }        
    
    text += endDelimiter;
    
    return text;        
}

VisitTag 包含的代码比其他所有方法都多,因为它还可以包含其他元素,包括其他必须自己管理的标签,因此不能简单地打印它们。我们在第 5 行保存了 ID 的内容,当然我们不需要检查相应的结束标记是否匹配,因为只要输入格式正确,解析器就会确保这一点。

第一个复杂问题从第 14-15 行开始:由于将一种语言转换为另一种语言时经常会发生这种情况,因此两者之间并不存在完美的对应关系。虽然 BBCode 试图成为 HTML 的更智能、更安全的替代品,但 Markdown 想要实现与 HTML 相同的目标,即创建结构化文档。所以 BBCode 有下划线标签,而 Markdown 没有。

所以我们必须做出决定

我们是想丢弃这些信息,还是直接打印 HTML,或者其他什么?我们选择其他东西,而不是将下划线转换为斜体。这似乎完全是武断的,而且在这个决定中确实有一个选择的因素。但是转换迫使我们丢失一些信息,两者都用于强调,所以我们选择新语言中更接近的东西。

以下第 18-22 行的情况迫使我们做出另一种选择。我们无法以结构化的方式维护有关引用作者的信息,因此我们选择以对人类读者有意义的方式打印信息。

在第 28-34 行,我们施展了我们的“魔法”:我们访问孩子并收集他们的文本,然后我们以 endDelimiter 结束。最后我们返回我们创建的文本。

这就是访问者的工作方式

  1. 每个top元素访问每个child
    • 如果是content节点,直接返回文本
    • 如果它是一个标签,它会设置正确的分隔符,然后检查它的子标签。它为每个孩子重复第 2 步,然后返回收集到的文本
  2. 它打印返回的文本

这显然是一个简单的示例,但它展示了如何在启动访问者后可以非常自由地管理访问者。结合我们在本节开头看到的模式,您可以看到所有选项:返回 null 以停止访问,返回 children 以继续,返回 something 以执行更高级别命令的操作树。

27. 高级测试

词汇模式的使用允许处理岛屿语言的解析,但它使测试复杂化。

我们不会显示 MarkupErrorListener.java 因为我们没有改变它;如果需要,可以在存储库中看到它。

您可以使用以下命令运行测试。

mvn test

现在我们来看看测试代码。我们跳过设置部分,因为那也很明显,我们只是复制在主文件中看到的过程,但我们只是添加我们的错误侦听器来拦截错误。

// private variables inside the class AppTest
private MarkupErrorListener errorListener;
private MarkupLexer markupLexer;
 
public void testText()
{
    MarkupParser parser = setup("anything in here");
 
    MarkupParser.ContentContext context = parser.content();        
    
    assertEquals("",this.errorListener.getSymbol());
}
 
public void testInvalidText()
{
    MarkupParser parser = setup("[anything in here");
 
    MarkupParser.ContentContext context = parser.content();        
    
    assertEquals("[",this.errorListener.getSymbol());
}
 
public void testWrongMode()
{
    MarkupParser parser = setup("author=\"john\"");                
 
    MarkupParser.AttributeContext context = parser.attribute(); 
    TokenStream ts = parser.getTokenStream();        
    
    assertEquals(MarkupLexer.DEFAULT_MODE, markupLexer._mode);
    assertEquals(MarkupLexer.TEXT,ts.get(0).getType());        
    assertEquals("author=\"john\"",this.errorListener.getSymbol());
}
 
public void testAttribute()
{
    MarkupParser parser = setup("author=\"john\"");
    // we have to manually push the correct mode
    this.markupLexer.pushMode(MarkupLexer.BBCODE);
 
    MarkupParser.AttributeContext context = parser.attribute(); 
    TokenStream ts = parser.getTokenStream();        
    
    assertEquals(MarkupLexer.ID,ts.get(0).getType());
    assertEquals(MarkupLexer.EQUALS,ts.get(1).getType());
    assertEquals(MarkupLexer.STRING,ts.get(2).getType()); 
    
    assertEquals("",this.errorListener.getSymbol());
}
 
public void testInvalidAttribute()
{
    MarkupParser parser = setup("author=/\"john\"");
    // we have to manually push the correct mode
    this.markupLexer.pushMode(MarkupLexer.BBCODE);
    
    MarkupParser.AttributeContext context = parser.attribute();        
    
    assertEquals("/",this.errorListener.getSymbol());
}

前两种方法和之前完全一样,我们只是检查没有错误,或者是正确的,因为输入本身是错误的。在第 30-32 行,事情开始变得有趣起来:问题是通过一条一条地测试规则,我们没有给解析器机会自动切换到正确的模式。所以它始终保持在 DEFAULT_MODE,在我们的例子中,这使得一切看起来都像 TEXT。这显然使得无法正确解析属性

同一行还显示了如何检查您所处的当前模式,以及解析器找到的标记的确切类型,我们用它来确认在这种情况下确实所有错误。

虽然我们可以使用一串文本来触发正确的模式,但每次都会使测试与几段代码交织在一起,这是一个禁忌。所以解决方案见第 39 行:我们手动触发正确的模式。完成后,您可以看到我们的属性被正确识别。

28. 处理表达式

到目前为止,我们已经编写了简单的解析器规则,现在我们将看到分析真实(编程)语言中最具挑战性的部分之一:表达式。虽然语句规则通常较大,但它们处理起来非常简单:您只需要编写一个规则来封装具有所有不同可选部分的结构。例如,一个 for 语句可以包含所有其他类型的语句,但我们可以简单地将它们包含在类似 statement*. 的表达式中,相反,可以以许多不同的方式组合。

一个表达式通常包含其他表达式。例如,典型的二进制表达式由左边的表达式、中间的运算符和右边的另一个表达式组成。这可能会导致歧义。例如,在表达式 5 + 3 * 2 处,对于 ANTLR,此表达式是不明确的,因为有两种解析它的方法。它可以将其解析为 5 + (3 * 2) 或 (5 +3) * 2。

直到这一刻,我们才避免了这个问题,因为标记结构围绕着应用它们的对象。所以在选择先申请哪一个时没有歧义:它是最外在的。想象一下,如果这个表达式写成:

<add>
    <int>5</int>
    <mul>
        <int>3</int>
        <int>2</int>
    </mul>
</add>

这将使 ANTLR 如何解析它变得显而易见。

这些类型的规则称为左递归规则。 您可能会说:只解析最先出现的内容。问题是语义上的:先加法,但我们知道乘法优先于加法。传统上解决这个问题的方法是创建一个复杂的特定表达式级联,如下所示:

expression     : addition;
addition       : multiplication ('+' multiplication)* ;
multiplication : atom ('*' atom)* ;
atom           : NUMBER ;

这样 ANTLR 就会知道首先搜索数字,然后搜索乘法,最后搜索加法。这既麻烦又违反直觉,因为最后一个表达式是第一个被实际识别的表达式。幸运的是,ANTLR4 可以自动创建类似的结构,因此我们可以使用更自然的语法

expression : expression '*' expression
           | expression '+' expression                      
           | NUMBER
           ;

在实践中,ANTLR 考虑我们定义备选方案的顺序来决定优先级。通过以这种方式编写规则,我们告诉 ANTLR 乘法优先于加法。

29.解析电子表格

现在我们准备在 C# 中创建我们的最后一个应用程序。我们将构建一个类似 Excel 的应用程序的解析器。实际上,我们希望管理您在电子表格单元格中编写的表达式。

grammar Spreadsheet;
 
expression          : '(' expression ')'                        #parenthesisExp
                    | expression (ASTERISK|SLASH) expression    #mulDivExp
                    | expression (PLUS|MINUS) expression        #addSubExp
                    | <assoc=right>  expression '^' expression  #powerExp
                    | NAME '(' expression ')'                   #functionExp
                    | NUMBER                                    #numericAtomExp
                    | ID                                        #idAtomExp
                    ;
 
fragment LETTER     : [a-zA-Z] ;
fragment DIGIT      : [0-9] ;
 
ASTERISK            : '*' ;
SLASH               : '/' ;
PLUS                : '+' ;
MINUS               : '-' ;
 
ID                  : LETTER DIGIT ;
 
NAME                : LETTER+ ;
 
NUMBER              : DIGIT+ ('.' DIGIT+)? ;
 
WHITESPACE          : ' ' -> skip;

到目前为止,您已经掌握了所有知识,除了可能的三件事外,一切都应该很清楚:

  1. 为什么有括号,
  2. 右边的东西是什么,
  3. 第 6 行的那件事。

括号放在第一位,因为它的唯一作用是为用户提供一种方法来覆盖运算符的优先级,如果需要的话。 AST 的这种图形表示应该很清楚。

右边的东西是标签,它们是用来让ANTLR为访问者或监听者生成特定的函数。因此将有一个VisitFunctionExp、一个VisitPowerExp 等。这可以避免使用一个巨大的访问者作为expression 规则。

与求幂相关的表达式是不同的,因为当您遇到两个相同类型的顺序表达式时,有两种可能的方法来对它们进行分组。第一个是先执行左边的,再执行右边的,第二个是相反的:这叫做关联性。通常您要使用的是左关联性,这是默认选项。尽管如此,求幂是右结合,因此我们必须向 ANTLR 发出这一信号。

另一种看待这个问题的方式是:如果有两个相同类型的表达,哪一个具有优先权:左边的还是右边的?同样,一张图片抵得上一千个单词。

我们还支持函数、代表单元格和实数的字母数字变量。

30. C#中的电子表格项目

您只需按照 C# 设置:安装运行时的 nuget 包和 Visual Studio 的 ANTLR4 扩展。每当您构建项目时,扩展都会自动生成所有内容:解析器、侦听器和/或访问者。

完成后,您还可以使用常用菜单添加 -> 新项目来添加语法文件。正是这样做以创建一个名为 Spreadsheet.g4 的语法,并将我们刚刚创建的语法放入其中。现在让我们看看主要的 Program.cs

using System;
using Antlr4.Runtime;
 
namespace AntlrTutorial
{
    class Program
    {
        static void Main(string[] args)
        {
            string input = "log(10 + A1 * 35 + (5.4 - 7.4))";
 
            AntlrInputStream inputStream = new AntlrInputStream(input);
            SpreadsheetLexer spreadsheetLexer = new SpreadsheetLexer(inputStream);
            CommonTokenStream commonTokenStream = new CommonTokenStream(spreadsheetLexer);
            SpreadsheetParser spreadsheetParser = new SpreadsheetParser(commonTokenStream);
 
            SpreadsheetParser.ExpressionContext expressionContext = spreadsheetParser.expression();
            SpreadsheetVisitor visitor = new SpreadsheetVisitor();
            
            Console.WriteLine(visitor.Visit(expressionContext));
        }
    }
}

没什么可说的,除此之外,当然,您还必须注意事物命名的另一个细微变化:注意外壳。例如,AntlrInputStream 在 C# 程序中是 ANTLRInputStream 在 Java 程序中。

您还可以注意到,这一次,我们在屏幕上输出访问者的结果,而不是将结果写入文件。

31. Excel 注定失败

我们将查看 Spreadsheet 项目的访问者。

public class SpreadsheetVisitor : SpreadsheetBaseVisitor<double>
{
    private static DataRepository data = new DataRepository();
 
    public override double VisitNumericAtomExp(SpreadsheetParser.NumericAtomExpContext context)
    {            
        return double.Parse(context.NUMBER().GetText(), System.Globalization.CultureInfo.InvariantCulture);
    }
 
    public override double VisitIdAtomExp(SpreadsheetParser.IdAtomExpContext context)
    {
        String id = context.ID().GetText();
 
        return data[id];
    }
 
    public override double VisitParenthesisExp(SpreadsheetParser.ParenthesisExpContext context)
    {
        return Visit(context.expression());
    }
 
    public override double VisitMulDivExp(SpreadsheetParser.MulDivExpContext context)
    {
        double left = Visit(context.expression(0));
        double right = Visit(context.expression(1));
        double result = 0;
 
        if (context.ASTERISK() != null)
            result = left * right;
        if (context.SLASH() != null)
            result = left / right;
 
        return result;
    }
 
    [..]
 
    public override double VisitFunctionExp(SpreadsheetParser.FunctionExpContext context)
    {
        String name = context.NAME().GetText();
        double result = 0;
 
        switch(name)
        {
            case "sqrt":
                result = Math.Sqrt(Visit(context.expression()));
                break;
 
            case "log":
                result = Math.Log10(Visit(context.expression()));
                break;
        }
 
        return result;
    }
}

VisitNumericVisitIdAtom 返回由文字数字或变量表示的实际数字。在真实场景中,DataRepository 将包含访问适当单元格中数据的方法,但在我们的示例中只是一个带有一些键和数字的字典。其他方法实际上以相同的方式工作:它们访问/调用包含表达式。唯一的区别是他们如何处理结果。

有些对结果执行操作,二进制操作以正确的方式组合两个结果,最后 VisitParenthesisExp 只是报告链上更高的结果。当数学由计算机完成时,它很简单。

32. 测试一切

到目前为止,我们只测试了解析器规则,也就是说,我们只测试了是否创建了正确的规则来解析我们的输入。现在我们还要测试访问者函数。这是一个理想的机会,因为我们的访问者返回值,我们可以单独检查。在其他情况下,例如,如果您的访问者将某些内容打印到屏幕上,您可能希望重写访问者以在流上写入。然后,在测试时,您可以轻松捕获输出。

我们不会显示 SpreadsheetErrorListener.cs,因为它与我们已经看到的前一个相同;如果你需要它,你可以在存储库中看到它。

要在 Visual Studio 上执行单元测试,您需要在解决方案中创建一个特定项目。您可以选择不同的格式,我们选择 xUnit 版本。要运行它们,菜单栏上有一个恰当命名的部分“TEST”。

[Fact]
public void testExpressionPow()
{
    setup("5^3^2");
 
    PowerExpContext context = parser.expression() as PowerExpContext;
 
    CommonTokenStream ts = (CommonTokenStream)parser.InputStream;   
 
    Assert.Equal(SpreadsheetLexer.NUMBER, ts.Get(0).Type);
    Assert.Equal(SpreadsheetLexer.T__2, ts.Get(1).Type);
    Assert.Equal(SpreadsheetLexer.NUMBER, ts.Get(2).Type);
    Assert.Equal(SpreadsheetLexer.T__2, ts.Get(3).Type);
    Assert.Equal(SpreadsheetLexer.NUMBER, ts.Get(4).Type); 
}
 
[Fact]
public void testVisitPowerExp()
{
    setup("4^3^2");
 
    PowerExpContext context = parser.expression() as PowerExpContext;
 
    SpreadsheetVisitor visitor = new SpreadsheetVisitor();
    double result = visitor.VisitPowerExp(context);
 
    Assert.Equal(double.Parse("262144"), result);
}
 
[..]
 
[Fact]
public void testWrongVisitFunctionExp()
{
    setup("logga(100)");
 
    FunctionExpContext context = parser.expression() as FunctionExpContext;
    
    SpreadsheetVisitor visitor = new SpreadsheetVisitor();
    double result = visitor.VisitFunctionExp(context);
 
    CommonTokenStream ts = (CommonTokenStream)parser.InputStream;
 
    Assert.Equal(SpreadsheetLexer.NAME, ts.Get(0).Type);
    Assert.Equal(null, errorListener.Symbol);
    Assert.Equal(0, result);
}
 
[Fact]
public void testCompleteExp()
{
    setup("log(5+6*7/8)");
 
    ExpressionContext context = parser.expression();
 
    SpreadsheetVisitor visitor = new SpreadsheetVisitor();
    double result = visitor.Visit(context);
 
    Assert.Equal("1.01072386539177", result.ToString(System.Globalization.CultureInfo.GetCultureInfo("en-US").NumberFormat));            
}

第一个测试函数与我们已经看到的类似;它检查是否选择了正确的标记。在第 11 行和第 13 行,您可能会惊讶地看到奇怪的标记类型,发生这种情况是因为我们没有明确地为“^”符号创建一个,所以自动为我们创建了一个。如果需要,您可以通过查看 ANTLR 生成的 *.tokens 文件来查看所有令牌。

在第 25 行,我们访问我们的测试节点并获得结果,我们在第 27 行检查。这一切都非常简单,因为我们的访问者很简单,而单元测试应该总是很容易并且由小部分组成,这真的再简单不过了比这个。

唯一需要注意的是和数字的格式有关,这里不是问题,但是看第59行,我们测试了一个完整表达式的结果。在那里我们需要确保选择了正确的格式,因为不同的国家使用不同的符号作为小数点。

有些事情取决于文化背景

如果您的计算机已经设置为 American English Culture 这将不是必需的,但为了保证每个人的正确测试结果,我们必须指定它。如果您正在测试与文化相关的事物,请记住这一点:例如数字分组、温度等。

在第 44-46 行,您会看到当我们检查错误函数时解析器实际工作。那是因为“logga”作为一个函数名在语法上确实是有效的,但它在语义上是不正确的。 “logga”函数不存在,所以我们的程序不知道如何处理它。因此,当我们访问它时,结果为 0。你还记得这是我们的选择:因为我们将结果初始化为 0 并且我们在 default 中没有 VisitFunctionExp. case 所以如果没有函数,结果仍然为 0。一种可能的替代方法是抛出异常。

最后的评论

在本节中,我们将看到在我们的示例中从未出现过但在您的程序中可能有用的提示和技巧。如果您想了解更多关于 ANTLR 的实践和理论,或者您需要处理最复杂的问题,我们建议您使用更多可能有用的资源。

33. 提示和技巧

让我们看看一些可能不时有用的技巧。在我们的例子中从来不需要这些,但它们在其他场景中非常有用。

包罗万象的规则

第一个是 ANY 词法分析器规则。这只是以下格式的规则。

	
ANY : . ;

这是一个包罗万象的规则,应该放在语法的末尾。它匹配在解析过程中未找到其位置的任何字符。因此,创建此规则可以在开发过程中为您提供帮助,当您的语法仍然存在许多可能导致分散注意力的错误消息的漏洞时。它甚至在生产过程中也很有用,就像矿井中的金丝雀一样。如果它出现在你的程序中,你就知道出了问题。

频道

还有一些我们还没有谈到的东西:频道。他们的用例通常是处理评论。你真的不想检查每个语句或表达式中的注释,所以你通常用 -> skip 把它们扔掉。但在某些情况下,您可能希望保留它们,例如,如果您正在翻译另一种语言的程序。发生这种情况时,您可以使用频道。已经有一个名为 HIDDEN 的可以使用,但您可以在词法分析器语法的顶部声明更多。

channels { UNIQUENAME }
// and you use them this way
COMMENTS : '//' ~[\r\n]+ -> channel(UNIQUENAME) ;

规则元素标签

除了区分同一规则的不同情况之外,标签还有另一种用途。它们可用于为通用规则或规则的一部分提供特定名称,通常但不总是具有语义值。格式为 label=rule,在另一个规则中使用。

expression : left=expression (ASTERISK|SLASH) right=expression ;

这样 leftright 将成为 ExpressionContext 节点中的字段。您可以使用 context.expression(0) 来引用同一实体,而不是使用 context.left

有问题的代币

在许多实际语言中,一些符号以不同的方式重复使用,其中一些可能会导致歧义。一个常见的问题示例是尖括号,它既用于移位表达式又用于定界参数化类型。

// bitshift expression, it assigns to x the value of y shifted by three bits
x = y >> 3;
// parameterized types, it define x as a list of dictionaries
List<Dictionary<string, int>> x;

定义位移运算符标记的自然方式是使用单个双尖括号“>>”。但这可能会导致将嵌套参数化定义与移位运算符混淆,例如此处显示的第二个示例。虽然解决该问题的一种简单方法是使用语义谓词,但过多的语义谓词会减慢解析阶段。解决方案是避免定义移位运算符标记,而是在解析器规则中使用两次尖括号,以便解析器本身可以为每种情况选择最佳候选者。

// from this
RIGHT_SHIFT : '>>';
expression : ID RIGHT_SHIFT NUMBER;
// to this
expression : ID SHIFT SHIFT NUMBER;

34.总结

我们今天学到了很多东西:

  • 什么是词法分析器和解析器
  • 如何创建词法分析器和解析器规则
  • 如何使用 ANTLR 在 Java、C#、Python 和 JavaScript 中生成解析器
  • 您将遇到的基本类型的解析问题以及如何解决这些问题
  • 如何理解错误
  • 如何测试你的解析器

这就是您自己使用 ANTLR 需要了解的全部内容。我的意思是从字面上看,您可能想了解更多,但现在您有了坚实的基础可以自行探索。

如果您需要有关 ANTLR 的更多信息,可以在哪里查看:

  • 在这个网站上有专门用于 ANTLR 的整个类别
  • 官方 ANTLR 网站 是了解项目总体状态、专业开发工具和相关项目(如 StringTemplate)的良好起点
  • GitHub 上的 ANTLR 文档;特别有用的是有关目标以及如何在不同语言上设置它的信息。
  • ANTLR 4.6 API;它与 Java 版本有关,因此在其他语言中可能存在一些差异,但这是解决您对该工具内部工作方式的疑虑的最佳场所。
  • 对于对 ANTLR4 背后的科学非常感兴趣的人,有一篇学术论文:Adaptive LL(*) Parsing: The Power of Dynamic Analysis
  • The Definitive ANTLR 4 Reference,作者是 ANTLR 的创建者 Terence Parr。如果您想了解关于 ANTLR 的一切以及大量关于一般解析语言的知识,您需要的资源。

这本书也是您唯一可以找到并回答以下问题的地方:

ANTLR v4 是我在研究生院读了一个小弯路(25 年)的结果。我想我将不得不稍微改变一下我的座右铭。

为什么要在五天内手工编写您可以花 25 年生活实现自动化的东西?

我们非常努力地构建了 ANTLR 上最大的教程:mega-tutorial!超过 13,000 字或超过 30 页的帖子,试图回答您关于 ANTLR 的所有问题。遗漏了什么? 联系我们现在让我们来帮助

标签2: Java教程
地址:https://www.cundage.com/article/jcg-antlr-mega-tutorial.html

相关阅读

Java HashSet 教程展示了如何使用 Java HashSet 集合。 Java哈希集 HashSet 是一个不包含重复元素的集合。此类为基本操作(添加、删除、包含和大小)提供恒定时间性...
SpringApplicationBuilder 教程展示了如何使用 SpringApplicationBuilder 创建一个简单的 Spring Boot 应用程序。 春天 是用于创建企业应...
通道是继 buffers 之后 java.nio 的第二个主要新增内容,我们在之前的教程中已经详细了解了这一点。通道提供与 I/O 服务的直接连接。 通道是一种在字节缓冲区和通道另一端的实体(通...
课程大纲 Elasticsearch 是一个基于 Lucene 的搜索引擎。它提供了一个分布式的、支持多租户的全文搜索引擎,带有 HTTP Web 界面和无模式的 JSON 文档。 Elasti...
解析器是强大的工具,使用 ANTLR 可以编写可用于多种不同语言的各种解析器。 在这个完整的教程中,我们将: 解释基础:什么是解析器,它可以用来做什么 查看如何设置 ANTLR 以便在 Java...
Java 是用于开发各种桌面应用程序、Web 应用程序和移动应用程序的最流行的编程语言之一。以下文章将帮助您快速熟悉 Java 语言,并迈向 API 和云开发等更复杂的概念。 1. Java语言...
Java中的继承是指子类继承或获取父类的所有非私有属性和行为的能力。继承是面向对象编程的四大支柱之一,用于提高层次结构中类之间的代码可重用性。 在本教程中,我们将了解 Java 支持的继承类型,...
Java Message Service 是一种支持正式通信的 API,称为 网络上计算机之间的消息传递。 JMS 为支持 Java 程序的标准消息协议和消息服务提供了一个通用接口。 JMS 提...
之前,我介绍了spring 3 + hibernate 集成 示例和struts 2 hello world 示例。在本教程中,我将讨论在将 spring 框架与 struts 与 hibern...
Java 项目中的一项常见任务是将日期格式化或解析为字符串,反之亦然。解析日期意味着你有一个代表日期的字符串,例如“2017-08-3”,你想把它转换成一个代表 Java 中日期的对象,例如Ja...