Одна из самых запомнившихся задач за три года коммерческой разработки — парсер бизнес-формул в аналитической системе. Выражения приходили строками из пользовательского ввода: арифметика, сравнения, логические связки, сорок с лишним функций, ссылки на поля модели данных, переменные, литералы дат. На выходе нужен был фрагмент SQL для колоночной БД плюс валидация типов.
Через эту задачу я плотно познакомился с ANTLR4: прошёл путь от пустого .g4 до продакшен-парсера, а дальше несколько лет курировал этот блок кода в команде.
Сегодня, оглядываясь на тот код, я вижу три центральные темы, которые сделал бы иначе. Расскажу про каждую и покажу, как это выглядит в коде. Плюс три небольшие заметки в конце — про что стоит подумать, когда вы только начинаете свой парсер.
Почему регулярки не подошли
На первых прикидках казалось: ну выражения, ну вложенные скобки, разберём регулярками. Первый же пример выбил эту идею:
@avg_score = (TOTAL([goals]) + 5) / [matches_played]; WHEN( SCOPE(TOTAL([goals]), [tournament] = 'Champions', [date] >= #01.07.2024#) > @avg_score, ROUND(DATESHIFT(MONTH, 1, #01.09.2024#), 0), @avg_score * 0.85 )
Здесь [goals] и [tournament] — ссылки на поля модели данных, @avg_score — пользовательская переменная, #01.07.2024#— литерал даты в формате dd.mm.yyyy.
Тут вложенные скобки нескольких уровней, вызовы функций с произвольным числом аргументов, приоритет операторов, контекстная зависимость (внутри WHEN первым аргументом идёт сравнение, а дальше — выражения), литералы дат с собственным синтаксисом, объявление переменной с последующей подстановкой. Регулярка не умеет рекурсию — скобки парой она не поймает в общем виде. Можно было бы попытаться свести задачу к конечному набору шаблонов, но каждый новый оператор или функция ломал бы уже написанное.
Нужен был настоящий парсер с деревом разбора. С этого начался ANTLR4.
Как ANTLR4 работает — базовый словарь для статьи
Чтобы дальше понимать разговор, нужно договориться о четырёх терминах.
Грамматика — файл .g4, в котором описывается синтаксис языка. ANTLR4 по нему генерирует лексер и парсер на целевом языке (у меня — Java).
Лексер превращает поток символов в поток токенов. Токен — это атомарная единица: число, идентификатор, оператор, ключевое слово.
Парсер собирает токены в дерево разбора (parse tree) по правилам грамматики.
Обходчики дерева — Listener или Visitor. ANTLR4 генерирует для них интерфейсы автоматически, вы реализуете методы под конкретные узлы.
Синтаксис грамматики выглядит так:
grammar Filter; // Корневое правило: программа состоит из объявлений переменных и выражения program : varDeclaration* expression EOF ; // Объявление переменной: @name = expr ; varDeclaration : VARIABLE '=' expression ';' ; // Правила парсера — с маленькой буквы expression : expression op=('*' | '/') expression # MulDiv | expression op=('+' | '-') expression # AddSub | expression op=('=' | '!=' | '<' | '>' | '<=' | '>=') expression # Compare | '(' expression ')' # Parens | functionCall # Function | VARIABLE # VarRef | IDENTIFIER # FieldRef | NUMBER # Number | STRING # StringLit | DATE_LITERAL # DateLit ; functionCall : NAME '(' (expression (',' expression)*)? ')' ; // Лексерные правила — с большой буквы NAME : [A-Z][A-Z_0-9]* ; // имена функций большими буквами VARIABLE : '@' [a-zA-Z_][a-zA-Z_0-9]* ; // @name IDENTIFIER : '[' ~[\]\r\n]+ ']' ; // [field_name] NUMBER : [0-9]+ ('.' [0-9]+)? ; STRING : '\'' ~['\r\n]* '\'' ; DATE_LITERAL : '#' [0-9][0-9] '.' [0-9][0-9] '.' [0-9][0-9][0-9][0-9] '#' ; WS : [ \t\r\n]+ -> skip ;
Несколько важных деталей, которые вам понадобятся:
Правила парсера именуются со строчной буквы (
expression,functionCall),
лексерные токены — с заглавной (IDENTIFIER,NUMBER,VARIABLE).|разделяет альтернативы в одном правиле.# MulDiv,# AddSub— это метки альтернатив. ANTLR сгенерирует для каждой отдельный метод в Listener/Visitor, что удобнее, чем один огромный метод с разбором черезif.-> skipв правилеWSговорит: «этот токен не включать в поток, это пробелы».op=('*' | '/')— это именованная ссылка на токен. В обработчике можно обратиться какctx.op.getType()и узнать, какой именно оператор распознался.~[\]\r\n]+в правилеIDENTIFIERозначает «любой символ, кроме закрывающей скобки и переноса строки».~[...]— это инверсия, сильное средство в лексерных правилах.
Это минимум, которого хватит для остального текста. Теперь переходим к центральным решениям.
Дерево разбора одного фрагмента
Чтобы видеть, что ANTLR4 на самом деле строит из формулы, разберём один фрагмент примера из лида — контекстную функцию с фильтрами:
SCOPE(TOTAL([goals]), [tournament] = 'Champions', [date] >= #01.07.2024#)
Дерево разбора этого фрагмента по нашей грамматике выглядит так:
Function └── functionCall ├── NAME: SCOPE └── arguments: ├── arg 0: Function TOTAL([goals]) │ └── functionCall │ ├── NAME: TOTAL │ └── arguments: │ └── FieldRef: [goals] ├── arg 1: Compare ([tournament] = 'Champions') │ ├── left: FieldRef: [tournament] │ ├── op: '=' │ └── right: StringLit: 'Champions' └── arg 2: Compare ([date] >= #01.07.2024#) ├── left: FieldRef: [date] ├── op: '>=' └── right: DateLit: #01.07.2024#
Корень — узел Function (метка альтернативы из правила expression), внутри него functionCall с тремя аргументами. Каждый аргумент — снова expression, потому что в грамматике аргументы функции описаны как (expression (',' expression)*)?. Поэтому в дереве естественно появляется вложенность: функция внутри функции, сравнение с литералом даты внутри аргумента.
Что важно увидеть на этом примере:
Каждое сравнение
=или>=создаёт узелCompareс тремя слотами —left,op,right. Это работа альтернативыexpression op=(...) expression # Compareиз грамматики.Литералы (
'Champions',#01.07.2024#) — листы дерева. Их разбирает лексер, парсер уже работает с готовыми токенами.Полное дерево всей формулы из лида занимает ~30 строк — оно построено по тем же принципам, просто крупнее. Здесь мы посмотрели на одну ветку, чтобы видеть форму.
Дерево — это вход для всех обходчиков, которые мы дальше обсудим. Visitor пойдёт по нему сверху вниз, возвращая значения; Listener пройдёт автоматически, дёргая enter и exit для каждого узла.
Решение 1. Приоритет операторов — через левую рекурсию, а не через Listener
Когда вы первый раз пишете грамматику для выражений, есть два очевидных искушения:
Искушение А. Написать плоское правило «операнд оператор операнд оператор...» без приоритетов, а приоритет воспроизвести в коде обработчика — собирать * и / первыми, потом + и -.
Искушение Б. Вспомнить классику и расписать слоистую пирамиду: addExpr → mulExpr → atom, где каждый слой — это выражение соответствующего приоритета.
Первое — антипаттерн: грамматика перестаёт отражать семантику. Парсер вернёт дерево без приоритетов, и правильную структуру придётся достраивать руками в обработчике. Любой другой обход этого дерева получит «неправильные» приоритеты — вы теперь обязаны помнить про свой костыль везде.
Второе — корректно, но громоздко. Для DSL с четырьмя уровнями приоритета это пять-шесть правил вместо одного.
Правильный способ в ANTLR4 — левая рекурсия с упорядоченными альтернативами. ANTLR4 умеет прямую левую рекурсию и автоматически разруливает приоритет: чем выше альтернатива в списке, тем выше её приоритет.
Возьмём фрагмент правила expression из нашей грамматики и посмотрим только на операторные альтернативы:
expression : expression op=('*' | '/') expression # MulDiv <- высший приоритет | expression op=('+' | '-') expression # AddSub | expression op=('=' | '!=' | '<' | '>' | '<=' | '>=') expression # Compare | ... // остальные альтернативы ;
Парсер автоматически свяжет * и / сильнее, чем + и -, а + и - — сильнее, чем сравнения. Достаточно правильно расставить альтернативы по убыванию приоритета.
Для входа 2 + 3 * 4 парсер построит дерево, где 3 * 4 — внутренний узел, а 2 + (3 * 4) — внешний. Без единой строчки кода.
Если где-то нужна правоассоциативность (например, у возведения в степень или тернарного оператора), это делается тэгом:
expression : expression '^' expression # Power | <assoc=right> expression '?' expression ':' expression # Ternary ;
Внутри Visitor это выглядит максимально скучно — как и должно быть:
@Override public BigDecimal visitMulDiv(FilterParser.MulDivContext ctx) { BigDecimal left = visit(ctx.expression(0)); BigDecimal right = visit(ctx.expression(1)); return switch (ctx.op.getText()) { case "*" -> left.multiply(right); case "/" -> left.divide(right, 10, RoundingMode.HALF_UP); default -> throw new IllegalStateException("Unknown op: " + ctx.op.getText()); }; }
Никакого ручного склеивания. Структура дерева — это и есть семантика, один в один.
Решение 2. Listener vs Visitor — выбирайте по природе задачи
ANTLR4 даёт два паттерна обхода дерева, и выбор между ними — это не вопрос вкуса, а вопрос того, что вы делаете.
Коротко про механику, чтобы дальше говорить на одном языке.
Listener запускается по готовому дереву через ParseTreeWalker.DEFAULT.walk(listener, tree), либо регистрируется прямо в парсере через parser.addParseListener(listener) и работает по ходу парсинга. В обоих режимах listener-ов можно повесить сколько угодно — парсер хранит их списком и на каждый узел дёргает по очереди. Методы enter<Rule>() и exit<Rule>() не возвращают значений: всё, что нужно накопить, живёт во внешнем состоянии (поле класса, стек, ParseTreeProperty).
Visitor парсеру не передаётся. Вы строите дерево, а потом вручную вызываете visitor.visit(tree). Управление рекурсией — на вас: вызываете visit(ctx.child()) там, где хотите спуститься. Каждый метод возвращает значение, и результат узла собирается из результатов детей снизу вверх. Никто не мешает запустить на одном дереве два разных Visitor-а, но на практике обычно нужен один — если от дерева нужен один составной результат, Visitor возвращает record сразу со всеми полями, а не делится на несколько обходчиков.
Простое правило выбора:
Нужен возвращаемый результат? — Visitor. Вычисление выражения, построение объекта, трансляция в другой AST — это всё Visitor.
Нужно собрать сайд-эффекты по дереву? — Listener. Валидация, подсветка синтаксиса, подсчёт количества определённых узлов, сбор символ-таблицы.
В моём коммерческом проекте было чёткое разделение ответственностей: один Visitor + три Listener-а, каждый из них отвечает за свою задачу:
Visitor — семантическая валидация и типизация. Для каждого подвыражения возвращает его тип, проверяет совместимость типов операндов, проверяет сигнатуры функций, собирает флаги (есть ли в поддереве агрегат, есть ли условная функция).
Listener №1 — транслятор в SQL. Использует
ParseTreeProperty<String>для хранения «уже сконвертированного фрагмента» на каждом узле, вexit-методах склеивает дочерние куски.Listener №2 — собирает колонки, попавшие под агрегат, для построения
GROUP BY.Listener №3 — строит шаблон выражения с плейсхолдерами для отдельного сценария.
Разделение задач между четырьмя обходчиками было осознанным применением Single Responsibility: каждый класс делает одну вещь, тесты на них независимые, поддерживать их можно параллельно. Это сработало — эти четыре класса спокойно жили и развивались параллельно в команде.
Но есть нюанс, про который я тогда не задумывался. Каждый из этих обходчиков делает свой проход по одному и тому же дереву. Четыре обхода. Для коротких выражений это незаметно. Для больших — это четырёхкратный объём работы, и часть логики между обходчиками неизбежно дублируется (например, и Visitor, и один из Listener-ов независимо определяют, есть ли в поддереве агрегатная функция).
Сегодня я бы собрал это одним Visitor-ом, возвращающим record FormulaResult(String sql, Type type, Set<String> columns, Flags flags, ...). Один обход, вся информация собрана снизу вверх, состояние «мы внутри агрегата» прокидывается параметром рекурсии, а не флагом во внешнем поле. Разделение ответственностей при этом сохраняется — просто переезжает из четырёх классов в четыре метода внутри одного.
Но если у вас нет проблем с производительностью и есть выигрыш в читаемости — разделять на Listener-ы тоже валидно. Главное — не смешивать Listener и Visitor «наугад». Два активных обходчика на одном дереве в одной задаче — это сигнал, что границы ответственности размыты.
Решение 3. Храните промежуточные результаты на узлах, а не в памяти пересчёта
Когда Visitor или Listener обрабатывает один узел, ему часто нужны результаты обработки детей — их типы, их транслированный SQL, их флаги. Есть три способа это сделать.
Способ первый, плохой. Взять ctx.getText() от поддерева и запустить парсинг заново в другом контексте. Соблазн большой: вроде же можно. У меня в коде это было, для нескольких узлов — объявления переменных и тело одной специальной функции парсились заново с другим набором параметров.
Что с этим не так:
getText()склеивает токены без пробелов.sum( [price] , [quantity] )послеgetText()становитсяsum([price],[quantity]). Если в вашем языке есть конструкции, чувствительные к исходному форматированию — теряете их.Локации ошибок нового парсинга относятся к подстроке, а не к исходной формуле. Пользователь видит «ошибка на позиции 5», но в исходной формуле это позиция, например, 47. Отладка становится болью.
Лексер запускается повторно для того же текста. ANTLR внутри кеширует DFA, но сам факт повторной токенизации — лишняя работа.
Все остальные Listener-ы и Visitor-ы вы тоже вызываете заново для поддерева. Если их четыре, это четыре обхода уже обойдённого.
Способ второй, ANTLR-ский. Использовать ParseTreeProperty<T> — это мапа «узел → произвольное значение», которую ANTLR даёт специально для аннотации дерева:
public class SqlTranslator extends FilterBaseListener { private final ParseTreeProperty<String> sqlFragments = new ParseTreeProperty<>(); @Override public void exitMulDiv(FilterParser.MulDivContext ctx) { String left = sqlFragments.get(ctx.expression(0)); String right = sqlFragments.get(ctx.expression(1)); String op = ctx.op.getText(); sqlFragments.put(ctx, "(" + left + " " + op + " " + right + ")"); } public String getResult(ParseTree root) { return sqlFragments.get(root); } }
Выходим из узла — кладём результат на узел. Выходим из родителя — читаем результаты детей с их узлов. Это работает одним проходом и не требует ни повторного парсинга, ни внешнего стека с ручным управлением.
Способ третий, для Visitor-а. Просто возвращать значение из метода. Это даже проще, чем ParseTreeProperty:
@Override public String visitMulDiv(FilterParser.MulDivContext ctx) { String left = visit(ctx.expression(0)); String right = visit(ctx.expression(1)); return "(" + left + " " + ctx.op.getText() + " " + right + ")"; }
Когда нужно пройти то же самое поддерево ещё раз с другим контекстом (у меня был этот сценарий для переменных и специальной функции) — не вызывайте парсер заново. Вызовите новый Visitor на том же узле:
String resultInContextA = new ContextAVisitor().visit(ctx.expression()); String resultInContextB = new ContextBVisitor().visit(ctx.expression());
Одно дерево — несколько обходчиков с разными задачами. Парсинг выполнен один раз, локации токенов сохранены, производительность в порядке.
Два файла грамматики: когда одна .g4 становится слишком большой
ANTLR4 поддерживает два режима грамматики:
Combined grammar — один файл, где лексер и парсер описаны вместе. Подходит для небольших DSL до пары сотен строк.
Split grammar — отдельно
FooLexer.g4(только лексерные правила) иFooParser.g4(только правила парсера, импортирует лексер черезoptions { tokenVocab = FooLexer; }). Для больших грамматик это стандартный приём.
У меня грамматика разрослась до объёма, при котором combined становилась неудобной — больше пятисот строк, сорок лексем, два языка ключевых слов (английский + локализованный), несколько десятков правил парсера. Я разделил на два файла сразу, как только стало ясно, что это не hello world на сто строк.
Внутри лексерного файла порядок правил важен: лексер матчит первое подходящее правило в порядке их объявления. Если у вас есть ключевые слова (AND, OR, NULL) и идентификаторы ([a-zA-Z_][a-zA-Z_0-9]*) — ключевые слова должны идти раньше идентификаторов, иначе любое ключевое слово попадёт под идентификатор.
lexer grammar FilterLexer; // Сначала ключевые слова AND : 'and' ; OR : 'or' ; NOT : 'not' ; NULL : 'null' ; // Потом идентификаторы IDENTIFIER : [a-zA-Z_][a-zA-Z_0-9]* ; // Потом литералы и пробелы NUMBER : [0-9]+ ('.' [0-9]+)? ; STRING : '\'' (~['\r\n])* '\'' ; WS : [ \t\r\n]+ -> skip ;
Для больших грамматик также полезны fragment-правила — вспомогательные куски, которые не генерируют токенов сами по себе, но используются в других лексерных правилах:
fragment DIGIT : [0-9] ; fragment LETTER : [a-zA-Z_] ; NUMBER : DIGIT+ ('.' DIGIT+)? ; IDENTIFIER : LETTER (LETTER | DIGIT)* ;
Это чистит грамматику и упрощает правки.
Ещё три вещи, о которых стоит подумать сразу
Трансляция — строка или целевое AST?
У меня Listener-транслятор собирал выходной SQL как строку через ParseTreeProperty<String>. Это работает, но каждый раз, когда нужно добавить новый трюк к выходному SQL (обернуть деление в decimal-функцию, добавить isNull(...) OR ... к каким-то условиям), приходится править склейку строки по месту. Для сложного выходного языка лучше строить целевой AST (свой или библиотечный — jOOQ, Calcite), и сериализовать его в SQL одним проходом. Тогда изменения выходного формата делаются в одном месте, а не в трёх exit-методах.
Типизация — не все узлы обязаны иметь тип.
В моей системе типов формулы было шесть значений: NUMBER, STRING, DATE, BOOLEAN, плюс два синтетических — KEYWORD (для аргументов-констант вроде единицы времени в функции «прибавить к дате») и MODIFIER_FUNCTION (для специальной конструкции внутри одной функции). Синтетические типы сделали систему единообразной, но это лишние сущности.
Правильнее признать: бывают узлы, у которых нет типа данных, потому что они не представляют значения. Такие узлы просто не участвуют в типовой проверке, а не получают «ненастоящий» тип для единообразия.
Возвращаемый объект парсера — разрезайте по сценариям.
У меня публичный API парсера был прост: один метод parse(String formula, ParserContext context) возвращающий FormulaData с пятнадцатью полями. Каждый вызывающий код брал из объекта свои 2-3 поля. Это удобно на старте, но превращается в бога-объект со временем.
Лучше сразу разделить возвращаемые типы по сценариям: ValidationResult для валидатора, TranslationResult для транслятора, MetadataResult для сервиса, которому нужна только структурная информация. Да, внутри парсер всё равно считает всё за один обход — но наружу отдаёт ровно то, что запрошено.
Итог
ANTLR4 — зрелый инструмент с продуманной архитектурой. Большая часть решений в нём уже правильная — нужно просто их знать:
Левая рекурсия с порядком альтернатив для выражений с приоритетом — вместо слоистых пирамид или ручной приоритезации в обработчике.
Один Visitor или один Listener с
ParseTreeProperty— вместо смеси нескольких активных обходчиков на одном дереве.Новый обходчик того же узла — вместо повторного парсинга через
getText().
Три центральных приёма, плюс привычки быстро уйти на split grammar при росте объёма, строить целевое AST вместо склейки строк и разделять возвращаемые типы по сценариям — и ваш парсер будет жить долго и не будет болеть, когда добавятся новые операторы, функции и типы.
Версии, которые я использовал: ANTLR4 4.13.2, Java 21, Maven plugin antlr4-maven-plugin.
Что дальше
Это вторая статья в серии материалов из моих проектов. Первая была про Spec-Driven Development на примере Telegram-бота — про то, как я теперь работаю с AI-ассистентами и что это сделало с моим инженерным процессом.
В следующей статье планирую разобрать:
FullStack web-приложение LifeSync (B2C-трекер привычек с гексагональной архитектурой, Kafka и jOOQ вместо JPA, React 19 + TypeScript, 251 тест).