Antlr - генератор парсеров для структурированных текстов. В файле с расширением .g4 описывается грамматика языка, затем antlr генерирует код парсера для одного из доступных target языков (java, c#, python2, python3, js, go, c++, swift). Новые фичи появляются сначала для java, затем для остальных.

На гитхабе есть репозиторий с кучей грамматик. Если повезет, можно найти нужную и не заморачиваться с ее написанием. Но, к сожалению, они там не все рабочие.

Например, когда мне понадобился лексер для скала кода - он не работал. Грамматика была описана, но по ней даже простейший hello world на токены не разбивался. Было несколько issue, но ни кто не хотел этим заниматься. На момент написания статьи я уже оформил pull request и его смержили в мастер.

О своем маленьком опыте с antlr я и хотел написать.

В интернете достаточно статей как написать свой калькулятор на antlr. У меня задача другая. Мне нужно взять существующую грамматику, понять почему она не работает, и пофиксить ее. Приступим. Для начала создадим новый проект… Самый удобный плагин для antlr оказался в gradle.

Нужно:

  • добавить antlr плагин
  • указать свежую версию antlr для плагина (по умолчанию 2.7.7 - очень старая)
  • указать версию antlr-runtime, для использования сгенерированного лексера/парсера
  • добавить аргументы и директорию, куда сложить .java классы
plugins {
    id 'scala'
    id 'antlr'
    id 'application'
}

mainClassName = 'ru.d10xa.antlr.Main'

dependencies {
    antlr "org.antlr:antlr4:4.7.1"
    compile "org.antlr:antlr4-runtime:4.7.1"
    compile "org.scala-lang:scala-library:2.12.6"
    testCompile "org.scalatest:scalatest_2.12:3.0.5"
}

repositories {
    jcenter()
}

generateGrammarSource {
    arguments += ['-visitor', '-listener', '-package', 'antlr']
    outputDirectory = new File(buildDir, "generated-src/antlr/main/antlr")
}

Скачиваем грамматику для scala

curl -o src/main/antlr/Scala.g4 -L https://github.com/antlr/grammars-v4/raw/master/scala/Scala.g4

Генерируем java классы

gradle generateGrammarSource

src/main/antlr это директория для грамматик по умолчанию для gradle плагина.

Для поиска ошибки нужно узнать на какие токены бьется scala код и какое дерево AST строится. Для этого я беру сгенерированый лексер и печатаю в консоль все токены по очереди, пока не встретится EOF (end of file) Обход дерева производит ParseTreeWalker. Для этого ему нужно указать вершину и передать ParseTreeListener. В Scala и Java вершины называются compilationUnit. Моя имплементация ParseTreeListener будет писать все что видит в println (входы и выходы из правил, а так же терминальные ноды и ошибки).

Код:

import java.nio.charset.Charset

import antlr.ScalaLexer
import antlr.ScalaParser
import org.antlr.v4.runtime.CharStreams
import org.antlr.v4.runtime.CommonTokenStream
import org.antlr.v4.runtime.Lexer
import org.antlr.v4.runtime.ParserRuleContext
import org.antlr.v4.runtime.Token
import org.antlr.v4.runtime.tree.ErrorNode
import org.antlr.v4.runtime.tree.ParseTreeListener
import org.antlr.v4.runtime.tree.ParseTreeWalker
import org.antlr.v4.runtime.tree.TerminalNode

object Main {
  def main(args: Array[String]) {
    val fileToParse = args(0)
    def lexer: ScalaLexer =
      new ScalaLexer(CharStreams.fromFileName(fileToParse, Charset.forName("UTF-8")))
    def tokens: CommonTokenStream = new CommonTokenStream(lexer)
    println("=====")
    printTokens(lexer)
    println("=====")
    val parser: ScalaParser = new ScalaParser(new CommonTokenStream(lexer))
    val walker = ParseTreeWalker.DEFAULT
    val parseTreeListener = new PrintlnParseTreeListener(parser)
    val compilationUnit = parser.compilationUnit
    walker.walk(parseTreeListener, compilationUnit)
  }

  def printTokens(lexer: ScalaLexer): Unit = {
    def tokenStream(lexer: Lexer): Stream[Token] =
      lexer.nextToken() match {
        case t if t.getType == Token.EOF => Stream.empty
        case t => t #:: tokenStream(lexer)
      }
    val tokensForPrint: Seq[String] =
      tokenStream(lexer).toList.zipWithIndex.map {
        case (token, index) =>
          val name = lexer.getVocabulary.getDisplayName(token.getType)
          val value = token.getText
          s"${index + 1}) $name: $value"
      }
    tokensForPrint foreach println
  }

  class PrintlnParseTreeListener(parser: ScalaParser) extends ParseTreeListener {
    override def visitTerminal(node: TerminalNode): Unit = {
      println(s"visitTerminal, $node")
    }

    override def visitErrorNode(node: ErrorNode): Unit = {
      println(s"visitErrorNode, $node")
    }

    override def enterEveryRule(ctx: ParserRuleContext): Unit = {
      val names: Array[String] = parser.getRuleNames
      println(s"enterEveryRule, ${names(ctx.getRuleIndex)} $ctx")
      println(ctx.getText)
    }

    override def exitEveryRule(ctx: ParserRuleContext): Unit = {
      println(s"exitEveryRule, $ctx")
    }
  }

}

Пример выхлопа для Scala.g4

package foo.bar

object HelloWorld {
  def main(args: Array[String]): Unit = {
    println("Hello, world!")
  }
}
=====
1) 'package': package
2) Id: example
3) 'object': object
4) Id: HelloWorld
5) '{': {
6) 'def': def
7) Id: main
8) '(': (
9) Id: args
10) ':': :
11) Id: Array
12) '[': [
13) Id: String
14) ']': ]
15) ')': )
16) ':': :
17) Id: Unit
18) '=': =
19) '{': {
20) Id: println
21) '(': (
22) StringLiteral: "Hello, world!"
23) ')': )
24) '}': }
25) '}': }
=====
enterEveryRule, compilationUnit []
packageexampleobjectHelloWorld
visitTerminal, package
enterEveryRule, qualId [1411]
example
visitTerminal, example
exitEveryRule, [1411]
enterEveryRule, topStatSeq [1420]
objectHelloWorld
enterEveryRule, topStat [1373 1420]
objectHelloWorld
enterEveryRule, tmplDef [1393 1373 1420]
objectHelloWorld
visitTerminal, object
enterEveryRule, objectDef [1237 1393 1373 1420]
HelloWorld
visitTerminal, HelloWorld
enterEveryRule, classTemplateOpt [1265 1237 1393 1373 1420]

exitEveryRule, [1265 1237 1393 1373 1420]
exitEveryRule, [1237 1393 1373 1420]
exitEveryRule, [1393 1373 1420]
exitEveryRule, [1373 1420]
exitEveryRule, [1420]
exitEveryRule, []

Для других грамматик можно использовать этот код, заменив ScalaLexer, ScalaParser на НазваниеГрамматикиLexer, НазваниеГрамматикиParser. Если parseTree в грамматике отличен от compilationUnit, можно указать другой интересующий.

Как запускать main class описывать не буду. Расскажу как я запускал gui. Я пробовал запускать через плагин к Intellij Idea, но у меня он часто вызывал полное зависание IDE. Видел даже специальный редактор для грамматик ANTLRWorks, выглядит круто(на youtube), но поддерживает только antlr3

Простой shell скрипт оказался самым удобным способом запустить gui.

#!/usr/bin/env bash

if [ "$#" -ne 3 ]; then
    echo "Illegal number of parameters"
    echo "> GrammarName startRuleName input-filename(s)"
    exit 1
fi

GRAMMAR_NAME="$1"
START_RULE_NAME="$2"
INPUT_FILENAMES="$3"
WORKDIR="$(pwd)/.antlr-tmp"
ANTLR_JAR="antlr-4.7.1-complete.jar"
ANTLR_JAR_PATH="${WORKDIR}/jar/${ANTLR_JAR}"

rm -r "$WORKDIR/build/"
mkdir -p "$WORKDIR/jar"
mkdir -p "$WORKDIR/build"

if ! [ -s  "$ANTLR_JAR_PATH" ]; then
    curl -o "$ANTLR_JAR_PATH" "https://www.antlr.org/download/$ANTLR_JAR"
fi

cp "$(pwd)/src/main/antlr/$GRAMMAR_NAME.g4" "$WORKDIR/build/"
cd "$WORKDIR/build/"
java -jar "${ANTLR_JAR_PATH}" "$GRAMMAR_NAME.g4"
export CLASSPATH="${WORKDIR}/build/:${ANTLR_JAR_PATH}"
javac *.java
cd -
java org.antlr.v4.gui.TestRig "$GRAMMAR_NAME" "$START_RULE_NAME" -gui "$INPUT_FILENAMES"

Редактирование файла .g4

Я пробовал idea и visual studio code. В idea кроме подсветки синтаксиса ничего не работает, или работает плохо. Даже самое необходимое “go to definition” не работает. В visual studio code редактировать гораздо удобнее. Go to definition работает, и даже при наведении курсора мыши показывает код правила для лексера или парсера. То есть даже прыгать к объявлению не приходится. Во вкладке OUTLINE можно фильтровать правила по названию.

Поиск и устранение ошибок

Опишу одну из ошибок, с которыми я столкнулся. Алгоритм решения был везде похожий. Строка def main(args: Array[String]): Unit разбилась на токены неверно. Это можно было увидеть по набору токенов, которые я вывел в println. Один из токенов был args:, вместо args. Смотрим название правила, по которому выделился токен. В строке 9) Id: args: правило называется Id. Само правило выглядит так:

Id : Plainid | '`' (CharNoBackQuoteOrNewline | UnicodeEscape | CharEscapeSeq )+ '`' ;

То есть Id в Scala это либо простой id, либо токен с опциональными особыми символами, но обернутый обратными апострофами. Тут и так понятно, что args: парсится как Plainid и проблема в нем. Но я объясню суть “дебага” именно на нем. Выносим Plainid как отдельное правило, чуть выше Id. Например, IdTemp.

IdTemp : Plainid ;

В токенах видим, что args: это IdTemp 9) IdTemp: args:. Значит нужно повторить операцию с Plainid, разбив его на более простые токены, и дальше станет понятно, откуда приехало двоеточие. Иногда, внимательного чтения достаточно, но в некоторых случаях, проще поредактировать файл с грамматикой и посмотреть как меняется набор токенов.

Заключение:

Antlr очень мощный инструмент для написания сложных парсеров. Там где не справляются регулярки(а именно, вложенные структуры) - antlr спешит на помощь.

Ссылки:

Офф. сайт antlr

Документация antlr

Набор грамматик grammars-v4

gradle antlr плагин

плагин к Intellij Idea

ANTLRWorks

Мой pull request в Scala.g4