ANTLR: неформальное введение

В этой статье я дам введение в мощный фреймворк ANTLR. С его помощью мы напишем небольшой язык, помогающий раскраивать лист металла (или любой другой лист). В начале язык будет простым, по мере написания следующих статей станет обрастать подробностями и в конце концов выльется во вполне работоспособный и полноценный. Статья может быть полезна всем, кто хочет быстро разобраться в том, как работает ANTLR.

Что такое ANTLR и зачем он нужен

ANTLR (ANother Tool for Language Recognition) is a powerful parser generator for reading,
processing, executing, or translating structured text or binary files.
It’s widely used to build languages, tools, and frameworks.
From a grammar, ANTLR generates a parser that can build and walk parse trees.
Terence Parr

Как следует из эпиграфа, ANTLR — это штуковина для генерации парсеров текста. С помощью их можно разобрать текст на составляющие и сказать, соответствует ли он заданным правилам; если да, произвести вычисления или выполнить другую работу.

Изначально ANTLR был создан для языка Java и написан на нем же, но на сегодняшний день для версии 4.7.2 доступна генерация парсеров для С#, Python 2 и 3, JavaScript, Go, C++ и Swift. Это значит, что парсер можно создать и для этих языков. Мы будем использовать Java, но все ниже сказанное без проблем можно перенести и на другие языки.

Что же можно делать с помощью ANTLR?

  1. Писать интерпретаторы и компиляторы новых языков программирования.
  2. Анализировать логи.
  3. Создавать утилиты для создания картинок из текстовых описаний (именно это мы и сделаем в данной статье).

Этапы разбора текста

Как происходит разбор любого структурированного текста? Почему структурированного? Да потому, что понимание любого текста — задача пока не решенная. Мы предполагаем, что имеем дело с подмножеством, подчиняющимся определенным правилам, которые называются грамматикой. О том, что это такое, я буду говорить в следующих статьях: дам развернутое определение и расскажу все, что нужно для применения его на практике. Но сейчас достаточно знать, что в начале нужно написать (или взять готовую) грамматику языка, который мы будем разрабатывать.

Что же мы будем делать с грамматикой:

  1. Разберем текст на составляющие, которые называются «лексемы». То есть выполним лексический анализ. Обычно лексемы — это слова текста. Заодно проверим, соответствуют ли наши системы описанным правилам.
  2. Сформируем из лексем более крупные структуры, опять же проверив, соответствуют ли они правилам.
  3. Получим дерево разбора грамматики. Это такое дерево, в корне которого главное правило, в узлах — промежуточные правила, в листьях — конкретные лексемы.
  4. Далее (опционально) можно проверить, соответствует ли наш текст правилам, которые с помощью грамматики учесть нельзя. Это называется семантический анализ.
  5. Обрабатываем ошибки, если они есть.
  6. Если ошибок нет или они для нас непринципиальны, обрабатываем дерево разбора.

Как же работает ANTLR

Вы, наверное, уже догадались, что разбор текстов — дело не простое. И это, таки да, правда. Лучшей в этом деле считается книга дракона — наиболее подробное и толковое руководство. Одна проблема — почти 1200 страниц! На изучение могут уйти годы. Но, к счастью, имеется ANTLR. Он по грамматике делает следующее:

  1. Генерирует класс, разбирающий текст на лексемы.
  2. Генерирует синтаксический анализатор.
  3. Говорит о том, что в лексике или синтаксисе текста есть ошибки (если они есть).
  4. Помогает обойти дерево синтаксического разбора, генерируя для этого классы визитора или листенера. В процессе разбора мы переопределяем нужные методы.

Лепота, большая часть работы сделана! По большому счету в ANTLR самое сложное — это создать грамматику для нужного языка, потом все — дальше работает фреймворк. Но поскольку у ANTLR есть обширное сообщество...

Создадим грамматику

В начале язык будет очень простым. У резака лазера (или любого другого) есть всего две базовые опции:

  • передвинуться в точку A в выключенном состоянии;
  • прорезать в листе отрезок прямой.

С помощью этих двух нехитрых операций можно вырезать в листе металла самую сложную фигуру, поскольку контур любой кривой можно сколько угодно точно приблизить маленькими отрезками прямых.

Таким образом, нам нужны всего две функции, MoveTo(x, y) и LineTo(x, y), где x и y — координаты соответствующей точки. Ниже представлен код нашей грамматики, давайте рассмотрим его.

grammar CuttingLanguage;

actions:action+;

action:moveTo|lineTo;
moveTo: moveToName='MoveTo' '(' x=INT ',' y=INT ')';

lineTo: lineToName='LineTo' '(' x=INT ',' y=INT ')';

INT :'-'? DIGIT+ ; 
fragment DIGIT : [0-9];

WS : [ \n\r\t] -> skip ;

Код всегда начинается с имени грамматики. Дальше идут правила. Грамматика без правил не имеет смысла, поэтому невозможна.

Правила могут состоять из других правил и символов, соединяющих их. Например, символ «+» в правиле «actions: action+;» означает, что в правиле actions правило action повторяется 1 или более раз. Если бы вместо «+» стояло «*», это означало бы, что правило повторяется 0 или более. Но в данном случае это не имеет смысла.

Теперь рассмотрим правило action. Вертикальная черта означает «или». Другими словами, правило action — это или moveTo, или lineTo.

Правило moveTo состоит из ключевого слова MoveTo, его метки скобок и переменных x и y типа INT. Правило INT начинается с большой буквы и отличается от вышеуказанных правил, это лексема. Мы отметим это и вернемся к разбору отличия лексем от других правил позже. INT состоит из опционального знака «-», на это указывает знак вопроса после него и одного или нескольких правил DIGIT.

В случае с DIGIT имеем сразу два интересных приема. Во-первых, перечисление в квадратных скобках. Это означает, что может быть любой знак, от 0 до 9. Во-вторых, DIGIT — это фрагмент. Он может быть только частью правила, но сам правилом не является.

И последнее правило — WS; стрелка и слово skip означают, что мы переправляем символы в определенный канал. Что это такое, мы поясним позже; сейчас важно просто знать, что пробел, табуляция и перенос строк в данной грамматике игнорируются.

Визитор

Одной грамматики мало. Нужно как-то обработать текст программы. Для этого есть два способа:

  1. Можно обрабатывать правила при входе и при выходе. Этот способ называется listener.
  2. Можно обрабатывать правило при входе в него и возвращать значение, которое будет обрабатываться правилом выше по иерархии. В конце концов, обработчик главного правила вернет искомое значение.

В данном разделе мы опишем реализацию второго способа, в следующем реализуем первый.

Давайте посмотрим на код.

Визитор наследуется от сгенерированного парсер-генератором класса, в данном случае это параметризируемый класс CuttingLanguageBaseVisitor. Конструктор принимает начальное положение точки и GraphicsContext, с помощью которого будем осуществлять рисование; для этого я использовал JavaFX. На GitHub проекта есть код, но следует отметить, что данная статья JavaFX не посвящена. «Просьба в пианиста не стрелять. Он играет, как умеет» ©. Именно поэтому...

В данном классе только два метода: visitMoveTo и visitLineTo. Первый принимает MoveToContext, это тоже сгенерированный парсером класс. У него имеются поля x и у. Помните, в грамматике мы написали x = INT; y = INT. x и y — это метки соответствующих типов. Они же соответствующие лексемы, их текст возвращается с помощью самоочевидного метода getText(), и этот код обрабатывается методом Integer.parseInt.

Далее, мы передвигаем перо в точку (X, Y) и сохраняем текущее положение с помощью gs.save(). Возвращаем точку — положение резака с обновленными координатами.

Метод lineTo работает аналогично, но перед сохранением рисует прямую в точку (X, Y).

package org.newlanguageservice.antlrtutorial;

import org.newlanguageservice.ch1.CuttingLanguageBaseVisitor;
import org.newlanguageservice.ch1.CuttingLanguageParser.LineToContext;
import org.newlanguageservice.ch1.CuttingLanguageParser.MoveToContext;

import javafx.scene.canvas.GraphicsContext;

public class CuttingLanguageVisitor extends CuttingLanguageBaseVisitor<Point> {
	private Point point;
	private GraphicsContext  gc;
	
	
	public CuttingLanguageVisitor(Point point, GraphicsContext gc) {
		
		this.point = point;
		this.gc=gc;
	}

	@Override
	public Point visitMoveTo(MoveToContext ctx) {
		int x = Integer.parseInt(ctx.x.getText());
		int y = Integer.parseInt(ctx.y.getText());
		gc.moveTo(x, y);
		gc.save();
		return point.setX(x).setY(y);
	}

	@Override
	public Point visitLineTo(LineToContext ctx) {
		int x = Integer.parseInt(ctx.x.getText());
		int y = Integer.parseInt(ctx.y.getText());
		gc.strokeLine(point.getX(), point.getY(), x, y);
		gc.save();
		return point.setX(x).setY(y);
	}
}

Теперь посмотрим, как это запустить. Вот код, передаваемый в слушатель кнопки запуска резака:

CuttingLanguageLexer lexer 
					= new CuttingLanguageLexer(new ANTLRInputStream(textArea.getText()));
				lexer.removeErrorListeners();
				CuttingLanguageParser parser
					= new CuttingLanguageParser(new CommonTokenStream(lexer));
				parser.removeErrorListeners();
				
				lexer.addErrorListener(new CuttingErrorListener());
				visitor = new CuttingLanguageVisitor(new Point(0, 0), gc);
				ParseTree tree = parser.actions();
				Point point = visitor.visit(tree);
				System.out.println(point);

Вначале мы создаем лексер. Это класс, который разбивает текст программы на лексемы (я писал о них выше), во второй строке создаем парсер из лексем. Потом создаем визитор.

Далее получаем корень дерева синтаксического разбора, это входная точка в дерево решений — правило actions. Можно было выбрать любое другое правило из грамматики (но не лексему), и разбор начинался бы с этого правила. С помощью визитора ходим по дереву и в конце получаем точку, в которую перейдет резак в завершение работы. Как видите, все просто.

Листенер

Не всегда в процессе обработки дерева синтаксического разбора нужно делать вычисления. Например, чтобы подкрасить ключевые слова, достаточно лишь получить их координаты. Для этого воспользуемся листенером. Работа этого класса несколько напоминает обход XML с помощью SAX: соответствующий метод класса срабатывает при входе и при выходе из правила.
Код класса представлен ниже:

package org.newlanguageservice.antlrtutorial;

import java.util.ArrayList;
import java.util.List;

import org.newlanguageservice.ch1.CuttingLanguageBaseListener;
import org.newlanguageservice.ch1.CuttingLanguageParser.LineToContext;
import org.newlanguageservice.ch1.CuttingLanguageParser.MoveToContext;

public class CuttingLanguageListener extends CuttingLanguageBaseListener {
	List<LexemeWithCoords> lexemes=new ArrayList<>();
	@Override
	public void enterLineTo(LineToContext ctx) {
		lexemes.add(new LexemeWithCoords(
				new Point(ctx.lineToName.getStartIndex(),
						ctx.lineToName.getStopIndex()+1), 
							"LineTo"));
	}

	@Override
	public void enterMoveTo(MoveToContext ctx) {
		lexemes.add(
				new LexemeWithCoords(
						new Point(ctx.moveToName.getStartIndex(),
								ctx.moveToName.getStopIndex()+1), "MoveTo"));
	}

	public List<LexemeWithCoords> getLexemes() {
		return lexemes;
	}
}

Как и в предыдущем случае, мы наследуемся от сгенерированного ANTLR базового класса и переопределяем два метода: enterLineTo и enterMoveTo, в которых получаем координаты ключевых слов в тексте.

После этого мы подкрашиваем текст в редакторе, вот так:

List<LexemeWithCoords> lexemes = listener.getLexemes();
				lexemes.forEach(lexeme->textArea.setStyleClass(lexeme.getCoords().getX(), 
						lexeme.getCoords().getY(), "keywords"));

Обработка ошибок

Иногда ошибки бывают в коде, и некоторые из них можно обнаружить с помощью ANTLR, а именно лексические и синтаксические. Ошибки смысловые, когда код написан в соответствии с грамматикой, но не делает нужного, обнаружить нельзя: фреймворк же не знает, чего вы хотите.

Лексер и парсер, которые мы сгенерировали, выбрасывают сообщение о ошибке. Нам надо всего лишь прикрепить к ним слушатель, в простейшем случае он просто выводит сообщение в консоль.

Листенер реализует интерфейс ANTLRErrorListener. В нем четыре метода, но в данный момент нас интересует лишь один — syntaxError.

Выглядит это так:

public class CuttingErrorListener implements ANTLRErrorListener {
	@Override
	public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine,
			String msg, RecognitionException e) {
		
		System.out.println(msg);
	}

Полный текст программы доступен по ссылке: github.com/...​imirkozhaev/antlrtutorial

В следующих статьях я покажу развёрнутое описание теории, необходимой для понимания ANTLR и принципов его работы.

Похожие статьи:
Співзасновник monobank Володимир Яценко спільно з інженерами-однодумцями запустив проєкт із виробництва безпілотників для української...
Intel анонсувала Intel Developer Cloud — платформу, призначену для того, щоб полегшити комерційним клієнтам отримання раннього доступу...
Время: понедельник+среда, 19:00-21:00Продолжительность: 6 недель 10 августа стартует курс Front-End Pro. Курс предназначен для людей,...
В рубрике DOU Проектор все желающие могут презентовать свой продукт (как стартап, так и ламповый pet-проект). Если вам есть...
Статья написана в соавторстве с Дмитрием Ховричем и Мэри Ротарь, Co-Founder IAMPM. Недавно мы с Дмитрием проводили вебинар...
Яндекс.Метрика