Это вторая статья из цикла “Разра­ба­тываем свой язык програм­ми­ро­вания на Java”, первую статью можно прочитать по ссылке.

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

Класси­фи­кация ошибок

Ошибки бывают разные, но в целом их можно разделить на три категории: синтак­си­ческиесеман­ти­ческие и ошибки времени испол­нения.

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

Пример синтак­си­ческой ошибки (отсут­ствует закры­вающая кавычка):

println("Hello, World!)

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

var a = 1 + 2 * 3

Ожидая, что переменная a будет равна 9, но на самом деле она будет равна 7. Это проис­ходит из-за того, что оператор умножения имеет более высокий приоритет, чем оператор сложения. Семан­ти­ческая ошибка обычно может быть обнаружена во время отладки или обширного тести­ро­вания программы.

Ошибки времени испол­нения, также известные как исклю­чения (Exceptions), возникают во время выпол­нения программы. Такие ошибки могут возникнуть из-за непра­вильного ввода данных, попытки доступа к несуще­ству­ющему файлу и многих других сценариев. Некоторые ошибки времени испол­нения могут быть обработаны в программе, но если этого не сделать, то обычно программа будет аварийно завершена.

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

JimpleBaseVisitor

Для выявления ошибок и преду­пре­ждений нам понадо­бится, знакомый из первой статьи абстрактный класс JimpleBaseVisitor (сгене­ри­рован ANTLR), который по-умолчанию реализует интерфейс JimleVisitor. Он позволяет обходить AST-дерево (Abstract Syntax Tree) и на основе анализа его узлов мы будем решать ошибка, преду­пре­ждение или нормальная часть кода. По сути, диагно­стика ошибок почти не отличается от интер­пре­тации кода, кроме случаев когда нам нужно выполнять ввод/вывод или обращаться к внешним ресурсам. Например, если выпол­няется команда вывода в консоль, то наша задача проверить допустимый ли тип данных передается в качестве аргумента, без непосред­ственного вывода в консоль.

Создадим класс JimpleDiagnosticTool, который наследует JimleBaseVisitor и будет инкап­су­ли­ровать в себе всю логику поиска и хранения ошибок:

class JimpleDiagnosticTool extends JimpleBaseVisitor<ValidationInfo> {
    private Set<Issue> issues = new LinkedHashSet<>();
}

record Issue(IssueType type, String message, int lineNumber, int lineOffset, String details) {}

Данный класс содержит в себе список типа Issue, который представляет инфор­мацию о конкретной ошибке.

Известно, что каждый метод данного класса должен возвращать значение опреде­ленного типа. В нашем случае мы будем возвращать инфор­мацию о типе узла в дереве — ValidationInfo. Также данный класс содержит инфор­мацию о возможном значении, это поможет нам выявлять некоторые семан­ти­ческие ошибки или ошибки времени выполнения.

record ValidationInfo(ValidationType type, Object value) {}

enum ValidationType {
    /**
     * Expression returns nothing.
     */
    VOID,

    /**
     * Expression is String
     */
    STRING,

    /**
     * Expression is double
     */
    DOUBLE,

    /**
     * Expression is long
     */
    NUMBER,

    /**
     * Expression is boolean
     */
    BOOL,

    /**
     * Expression contains error and analysing in another context no makes sense.
     */
    SKIP,

    /**
     * When object can be any type. Used only in Check function definition mode.
     */
    ANY,

    /**
     * Tree part is function declaration
     */
    FUNCTION_DEFINITION
}

Следует обратить внимание на значение ValidationType.SKIP. Оно будет исполь­зо­ваться в случае если в части дерева была найдена и уже зареги­стри­рована ошибка, и дальнейший анализ этого узла дерева не имеет смысла. Например, если в выражении суммы один аргумент содержит ошибку, то анализ второго аргумента выражения не будет проводиться.

ValidationInfo checkBinaryOperatorCommon(ParseTree leftExp, ParseTree rightExp, Token operator) {
    ValidationInfo left = visit(leftExp);
    if (left.isSkip()) {
        return ValidationInfo.SKIP;
    }
    ValidationInfo right = visit(rightExp);
    if (right.isSkip()) {
        return ValidationInfo.SKIP;
    }
    // code omitted
}

Listeners vs Visitors

Перед тем как двигаться дальше, давайте посмотрим на еще один сгене­ри­ро­ванный ANTLR-ом интерфейс JimpleListener (шаблон Observer), который тоже может быть исполь­зован, если нам нужно обходить AST-дерево. В чем разница между ними? Самое большое различие между этими механизмами в том, что методы listener вызываются ANTLR-ом для каждого узла всегда, тогда как методы visitor должны обходить свои дочерние элементы явными вызовами. И если программист не вызывает visit() на дочерних узлах, то эти узлы не посещаются, т.е. у нас есть возмож­ность управлять обходом дерева. Например, в нашей реали­зации тело функции посещается сначала один раз полностью (режим checkFuncDefinition==true) для выявления ошибок во всей функции (все блоки if и else), и несколько раз с конкретными значе­ниями аргументов:

@Override
ValidationInfo visitIfStatement(IfStatementContext ctx) {
    // calc expression in "if" condition
    ValidationInfo condition = visit(ctx.expression());

    if (checkFuncDefinition) {
        visit(ctx.statement());
        // as it's just function definition check, check else statement as well
        JimpleParser.ElseStatementContext elseStatement = ctx.elseStatement();
        if (elseStatement != null) {
            visit(elseStatement);
        }
        return ValidationInfo.VOID;
    }

    // it's not check function definition, it's checking of certain function call
    if (condition.isBool() && condition.hasValue()) {
        if (condition.asBoolean()) {
            visit(ctx.statement());
        } else {
            JimpleParser.ElseStatementContext elseStatement = ctx.elseStatement();
            if (elseStatement != null) {
                visit(elseStatement);
            }
        }
    }

    return ValidationInfo.VOID;
}

Шаблон Visitor работает очень хорошо, если нам необходимо спроеци­ровать опреде­ленное значение для каждого узла дерева. Это именно то, что нам нужно.

Отлов синтак­си­ческих ошибок

Для того чтобы найти в коде некоторые синтак­си­ческие ошибки, нам необходимо реали­зовать интерфейс ANTLRErrorListener. Данный интерфейс содержит четыре метода, которые будут вызываться (парсером и/или лексером) в случае ошибки или неопре­де­ленного поведения:

interface ANTLRErrorListener {
    void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e);
    void reportAmbiguity(Parser recognizer, DFA dfa, int startIndex, int stopIndex, boolean exact, BitSet ambigAlts, ATNConfigSet configs);
    void reportAttemptingFullContext(Parser recognizer, DFA dfa, int startIndex, int stopIndex, BitSet conflictingAlts, ATNConfigSet configs);
    void reportContextSensitivity(Parser recognizer, DFA dfa, int startIndex, int stopIndex, int prediction, ATNConfigSet configs);
} 

Название первого метода (syntaxError) говорит само за себя, он будет вызываться в случае синтак­си­ческой ошибки. Реали­зация довольно простая: нам нужно преоб­ра­зовать инфор­мацию об ошибке в объект типа Issue и добавить его в список ошибок:

@Override
void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) {
    int offset = charPositionInLine + 1;
    issues.add(new Issue(IssueType.ERROR, msg, line, offset, makeDetails(line, offset)));
}

Остальные три метода можно игнори­ровать. Также ANTLR сам реализует этот интерфейс (класс ConsoleErrorListener) и отправляет ошибки в стандартный поток ошибок (System.err). Чтобы отключить его и другие стандартные обработчики, нам необходимо вызвать метод removeErrorListeners у парсера и лексера:

    // убираем стандартные обработчики ошибок
    lexer.removeErrorListeners();
    parser.removeErrorListeners();

Другой тип синтак­си­ческих ошибок базируется на правилах конкретного языка. Например, в нашем языке функция иденти­фи­ци­руется по имени и количеству аргументов. Когда анали­затор встречает вызов функции, то он проверяет, существует ли функция с таким именем и количе­ством аргументов. Если нет, то он выдает ошибку. Для этого нам необходимо переопре­делить метод visitFunctionCall:

@Override
ValidationInfo visitFunctionCall(FunctionCallContext ctx) {
    String funName = ctx.IDENTIFIER().getText();
    int argumentsCount = ctx.expression().size();
    var funSignature = new FunctionSignature(funName, argumentsCount, ctx.getParent());
    // ищем функцию в контексте по сигнатуре (имя+количество аргументов)
    var handler = context.getFunction(funSignature);

    if (handler == null) {
        addIssue(IssueType.ERROR, ctx.start, "Function with such signature not found: " + funName);
        return ValidationInfo.SKIP;
    }

    // code omitted
}

Давайте проверим конструкцию if. Jimple требует, чтобы выражение в условии if было типа boolean:

@Override
ValidationInfo visitIfStatement(IfStatementContext ctx) {
    // visit expression
    ValidationInfo condition = visit(ctx.expression());
    // skip if expression contains error
    if (condition.isSkip()) {
        return ValidationInfo.SKIP;
    }

    if (!condition.isBool()) {
        addIssue(IssueType.WARNING, ctx.expression().start, "The \"if\" condition must be of boolean type only. But found: " + condition.type());
    }

    // code omitted
}

Внима­тельный читатель заметит, что в данном случае мы добавили преду­пре­ждение, а не ошибку. Это сделано из-за того, что наш язык является динами­ческим и нам не всегда известна точная инфор­мация о типе выражения.

Поиск семан­ти­ческих ошибок

Как уже было сказано ранее, семан­ти­ческие ошибки сложны в поиске и часто могут быть найдены только во время отладки или тести­ро­вания программы. Однако, некоторые из них можно выявить на этапе компи­ляции. Например, если мы знаем, что функция X всегда возвращает значение 0, то мы можем выдать преду­пре­ждение, если в выражении деления в качестве делителя исполь­зуется данная функция. Деление на ноль обычно считается семан­ти­ческой ошибкой, поскольку деление на ноль не имеет смысла в математике.

Пример детек­ти­ро­вания ошибки “Деление на ноль”: сраба­тывает в случае когда в качестве делителя исполь­зуется выражение, которое всегда возвращает значение 0:

ValidationInfo checkBinaryOperatorForNumeric(ValidationInfo left, ValidationInfo right, Token operator) {
    if (operator.getType() == JimpleParser.SLASH && right.hasValue() && ((Number) right.value()).longValue() == 0) {
        // if we have value of right's part of division expression and it's zero
        addIssue(IssueType.WARNING, operator, "Division by zero");
    }

    // code omitted
}

Ошибки времени исполнения

Ошибки времени испол­нения также тяжело или даже невоз­можно обнаружить на этапе компиляции/интерпретации. Однако, некоторые подобные ошибки всё же можно выявить. Например, если функция вызывает сама себя (напрямую или через другую функцию), то это может привести к ошибке перепол­нения стека (StackOverflow). Первое что нам нужно сделать – это объявить список (Set), где мы будем сохранять функции, которые находятся в процессе вызова в данной момент. Саму проверку можно (и нужно) разме­стить в методе handleFuncInternal обработки вызова функции. В начале этого метода находится проверка наличия текущего FunctionDefinitionContext (контекст объяв­ления функции) в списке уже вызванных функций, и если да, то регистрируем преду­пре­ждение (Warning) и прерываем дальнейшую обработку функции. Если нет, то добавляем текущий контекст в наш список, и далее следует остальная логика. При выходе из handleFuncInternal нужно удалить из списка текущий контекст функции. Здест следует обратить внимание, что в данном случае мы не только выявили потен­ци­альный StackOverflow, но и избавились от этой же ошибки во время проверки кода, а именно при выполении зацик­ли­вания метода handleFuncInternal.

Set<FunctionDefinitionContext> calledFuncs = new HashSet<>();

ValidationInfo handleFuncInternal(List<String> parameters, List<Object> arguments, FunctionDefinitionContext ctx) {
    if (calledFuncs.contains(ctx)) {
        addIssue(IssueType.WARNING, ctx.name, String.format("Recursive call of function '%s' can lead to StackOverflow", ctx.name.getText()));
        return ValidationInfo.SKIP;
    }
    calledFuncs.add(ctx);
    
    // other checkings

    calledFuncs.remove(ctx);

    // return resulting ValidationInfo
}

Анализ потока управления/данных

Для более глубокого иссле­до­вания программного кода, оптими­зации и выявления сложных ошибок также используют Анализ потока управ­ления (Control-flow analysis) и Анализ потока данных (Data-flow analysis).

Анализ потока управ­ления фокуси­руется на понимании того, какие части программы выпол­няются в зависи­мости от различных условий и управ­ляющих структур, таких как условные операторы (if-else), циклы и переходы. Он позволяет выявить пути выпол­нения программы и иденти­фи­ци­ровать потен­ци­альные ошибки, связанные с непра­вильной логикой управ­ления потоком. Например, недости­жимый код или потен­ци­альные точки зависания программы.

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

Резюме

В этой статье мы рассмотрели, как добавить диагно­стику ошибок и преду­пре­ждений в свой язык програм­ми­ро­вания. Узнали, какие инстру­менты из коробки предо­ставляет ANTLR для регистрации синтак­си­ческих ошибок. Реали­зовали обработку некоторых ошибок и потен­ци­альных проблем во время выпол­нения программы.

Весь исходный код интер­пре­татора можно посмотреть по ссылке.

Ссылки

Это первая статья из цикла “Разра­ба­тываем свой язык програм­ми­ро­вания на Java”, который на примере разра­ботки простого языка, покажет полный путь создания языка, а также написания и поддержки инстру­ментов для него. К концу данной статьи реализуем интер­пре­татор, с помощью которого можно будет выполнять программы на нашем языке.

Любой язык програм­ми­ро­вания имеет синтаксис, который необходимо преоб­ра­зовать в удобную для валидации, преоб­ра­зо­вания и испол­нения структуру данных. Как правило, такой струк­турой данных выступает абстрактное синтак­си­ческое дерево (abstract syntax tree, AST). Каждый узел дерева обозначает конструкцию, встре­ча­ю­щуюся в исходном коде. Исходный код разби­рается парсером и на выходе получается AST.

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

В данной статье рассмотрим инструмент ANTLR. ANTLR – утилита, на вход которой подается грамматика в виде РБНФ, а на выходе получаем интерфейсы/классы (в нашем случае это java-код) для разбора программ. Список языков, на которых генери­руются парсеры, можно найти здесь.

Пример грамматики

Перед тем как перейти к реальной грамматике, попробуем на словах описать некоторые правила типичного языка программирования:

  • ПЕРЕМЕННАЯ – это ИДЕНТИФИКАТОР
  • ЦИФРА – это один из символов 0 1 2 3 4 5 6 7 8 9
  • ЧИСЛО – это один или более элементов типа ЦИФРА
  • ВЫРАЖЕНИЕ – это ЧИСЛО
  • ВЫРАЖЕНИЕ – это ПЕРЕМЕННАЯ
  • ВЫРАЖЕНИЕ – это ВЫРАЖЕНИЕ ‘+’ ВЫРАЖЕНИЕ
  • ВЫРАЖЕНИЕ – это ‘(‘ ВЫРАЖЕНИЕ ’)’

Как видно из данного списка, языковая грамматика – это набор правил, которые могут иметь рекур­сивные связи. Каждое правило может ссылаться на себя или на любое другое правило. ANTLR в своем арсенале имеет множество опера­торов для описания таких правил.

: метка начала правила
; метка конца правила
| оператор альтернативы
.. оператор диапазона
~ отрицание
. любой символ
= присваивание
(...) подправило
(...)* повторение подправила 0 или более раз
(...)+ Повторение подправила 1 или более раз
(...)? подправило, может отсутствовать
{...} семантические действия (на языке, использующемся в качестве выходного – например, Java)
[...] параметры правила

Примеры правил на ANTLR

Следующий пример описывает правила для целых чисел и чисел с плавающей точкой:

NUMBER : [0-9]+ ;
FLOAT  : NUMBER '.' NUMBER ;

Очень важно понимать, что грамматика описывает только синтаксис языка, на основе которого будет генери­ро­ваться парсер. Парсер будет генери­ровать AST, используя который, можно будет реали­зовать семантику языка. В преды­дущем примере, мы задали правило для разбора целого числа, но не описали какой объем памяти занимает число (8 бит, 16, …), является ли число со знаком или без. Например, в некоторых языках програм­ми­ро­вания переменную можно начать исполь­зовать, не объявив ее. Также можно не объявлять тип переменной, в этом случае тип будет определен автома­ти­чески в рантайме. Все эти правила семантики языка не описы­ваются в грамматике, а реали­зуются в другой части языка.

Лексемы и выражения ANTLR

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

Лексемы (или токены) – это правила, которые определяют отдельные лекси­ческие элементы входного языка, такие как числа, иденти­фи­каторы, знаки операций и т.д. Каждой лексеме соответ­ствует опреде­ленный тип токена, который исполь­зуется для дальнейшей обработки синтак­си­ческим анали­за­тором. Лекси­ческий анали­затор сканирует входной текст, разбивает его на лексемы и создает после­до­ва­тель­ность токенов, которые затем передаются синтак­си­че­скому анали­затору. Записы­ваются в верхнем регистре (Например: NUMBERIDENTIFIER).

Выражения – это правила, которые определяют структуру грамматики входного языка. Они описывают, каким образом лексемы связаны между собой и как они могут быть объединены в более сложные конструкции. Выражения могут содержать ссылки на лексемы, а также на другие выражения. Записы­ваются в нотации сamelCase (Например: expressionfunctionDefinition).

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

Требо­вания к языку

Перед тем как начать реали­зо­вывать язык необходимо опреде­литься с набором возмож­ностей, которые он должен поддер­живать. Для нашей задачи, в образо­ва­тельных целях, мы будем исполь­зовать простую грамматику. Язык будет поддер­живать следующие конструкции:

  • Переменные (типов StringLongDouble);
  • Оператор присва­и­вания (=);
  • Арифме­ти­ческие операции (+, -, *, /);
  • Операторы сравнения (>, <, >=, <=, ==, !=);
  • Условные операторы (if, else);
  • Функции;
  • Печать в консоль (встро­енный оператор println).

Грамматика

Ну и наконец, полное описание грамматики для языка:

grammar Jimple;

// корневое правило грамматики
program: (statement)* EOF;

// список возможных утверждений
statement: variableDeclaration
         | assignment
         | functionDefinition
         | functionCall
         | println
         | return
         | ifStatement
         | blockStatement
         ;

// список возможных выражений
expression: '(' expression ')'                                      #parenthesisExpr
          | left=expression op=(ASTERISK | SLASH) right=expression  #mulDivExpr
          | left=expression op=(PLUS | MINUS) right=expression      #plusMinusExpr
          | left=expression compOperator right=expression           #compExpr
          | IDENTIFIER                                              #idExp
          | NUMBER                                                  #numExpr
          | DOUBLE_NUMBER                                           #doubleExpr
          | STRING_LITERAL                                          #stringExpr
          | functionCall                                            #funcCallExpr
          ;

// описания отдельных выражений и утверждений
variableDeclaration: 'var' IDENTIFIER '=' expression ;

assignment: IDENTIFIER '=' expression ;

compOperator: op=(LESS | LESS_OR_EQUAL | EQUAL | NOT_EQUAL | GREATER | GREATER_OR_EQUAL) ;

println: 'println' expression ;

return: 'return' expression ;

blockStatement: '{' (statement)* '}' ;

functionCall: IDENTIFIER '(' (expression (',' expression)*)? ')' ;

functionDefinition: 'fun' name=IDENTIFIER '(' (IDENTIFIER (',' IDENTIFIER)*)? ')' '{' (statement)* '}' ;

ifStatement: 'if' '(' expression ')' statement  elseStatement? ;

elseStatement: 'else' statement ;

// список токенов
IDENTIFIER          : [a-zA-Z_] [a-zA-Z_0-9]* ;
NUMBER              : [0-9]+ ;
DOUBLE_NUMBER       : NUMBER '.' NUMBER ;
STRING_LITERAL      : '"' (~["])* '"' ;

ASTERISK            : '*' ;
SLASH               : '/' ;
PLUS                : '+' ;
MINUS               : '-' ;

ASSIGN              : '=' ;
EQUAL               : '==' ;
NOT_EQUAL           : '!=' ;
LESS                : '<' ;
LESS_OR_EQUAL       : '<=' ;
GREATER             : '>' ;
GREATER_OR_EQUAL    : '>=' ;

SPACE               : [ \r\n\t]+ -> skip;
LINE_COMMENT        : '//' ~[\n\r]* -> skip;

Как вы уже, наверное, догадались, наш язык называется Jimple (проис­ходит от Jvm Simple). Пожалуй, стоит объяснить некоторые моменты, которые могут быть неоче­видными при первом знакомстве с ANTLR.

Метки

При описании правил некоторых операций была исполь­зована метка op. Это позволяет нам в дальнейшем исполь­зовать эту метку в качестве имени переменной, которая будет содержать значение оператора. В принципе, можно было бы обойтись без указания меток, но в таком случае придется писать допол­ни­тельный код, чтобы достать значение оператора из дерева разбора.

compOperator: op=(LESS | LESS_OR_EQUAL | EQUAL | NOT_EQUAL | GREATER | GREATER_OR_EQUAL) ;

Имено­ванные альтер­нативы правил

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

expression: '(' expression ')'  #parenthesisExpr
          | IDENTIFIER          #idExp
          | NUMBER              #numExpr

ANTLR сгене­рирует следующий код:

public interface JimpleVisitor<T> {
    T visitParenthesisExpr(ParenthesisExprContext ctx);

    T visitIdExp(IdExpContext ctx);

    T visitNumExpr(NumExprContext ctx);
}

Каналы

В ANTLR существует такая конструкция как канал (channel). Обычно каналы исполь­зуются для работы с коммен­та­риями, но так как в большинстве случаев нам не нужно проверять наличие коммен­тариев их необходимо отбросить с помощью -> skip, чем мы и восполь­зо­вались. Однако, бывают случаи, когда нам нужно интер­пре­ти­ровать значение коммен­тариев или других конструкций, тогда вы исполь­зуете каналы. В ANTLR уже есть встро­енный канал под названием HIDDEN, который вы можете исполь­зовать, или объявить свои каналы для опреде­ленных целей. Далее при разборе кода можно будет получить доступ к этим каналам.

Пример объяв­ления и исполь­зо­вания канала

channels { MYLINECOMMENT }

LINE_COMMENT : '//' ~[rn]+ -> channel(MYLINECOMMENT) ;

Фрагменты

На ряду с токенами (лексемами) в ANTLR присут­ствует такое понятие как фрагменты (fragment). Правила с префиксом фрагмента могут быть вызваны только из других правил лексера. Они не являются токенами сами по себе. В следующем примере мы вынесли во фрагменты опреде­ления чисел для разных систем счисления.

NUMBER: DIGITS | OCTAL_DIGITS | HEX_DIGITS;
fragment DIGITS: '1'..'9' '0'..'9'*;
fragment OCTAL_DIGITS: '0' '0'..'7'+;
fragment HEX_DIGITS: '0x' ('0'..'9' | 'a'..'f' | 'A'..'F')+;

Таким образом, число в любой системе счисления (например: «123», «0762» или «0xac1») будет рассмат­ри­ваться как токен NUMBER, а не DIGITS, OCTAL_DIGITS или HEX_DIGITS. В Jimple фрагменты не используются.

Инстру­менты

Перед тем как приступить к генерации парсера, нам нужно настроить инстру­менты для работы с ANTLR. Как известно, хороший и удобный инструмент — это половина успеха. Для этого нам понадо­бится скачать ANTLR библиотеку и написать скрипты для его запуска. Существуют также maven/gradle/IntelliJ IDEA плагины, которые мы не будем исполь­зовать в этой статье, но для продук­тивной разра­ботки они могут быть полезны.

Нам понадо­бятся следующие скрипты:

Скрипт antlr4.sh

java -Xmx500M -cp ".:/usr/lib/antlr-4.12.0-complete.jar" org.antlr.v4.Tool $@

Скрипт grun.sh

java -Xmx500M -cp ".:/usr/lib/antlr-4.12.0-complete.jar" org.antlr.v4.gui.TestRig $@

Генерация парсера

Сохраните грамматику в файле Jimple.g4. Далее запустите скрипт следующим образом:

antlr4.sh Jimple.g4 -package org.jimple.lang -visitor

Параметр -package позволяет указать java package, в котором будет сгене­ри­рован код. Параметр -visitor позволяет сгене­ри­ровать интерфейс JimpleVisitor, который реализует паттерн Visitor.

После успешного выпол­нения скрипта в текущей дирек­тории появятся несколько файлов: JimpleParser.javaJimpleLexer.javaJimpleListener.javaJimpleVisitor.java. Первые два файла содержат сгене­ри­ро­ванный код парсера и лексера соответ­ственно. Остальные два файла содержат интер­фейсы для работы с деревом разбора. В этой статье мы будем исполь­зовать интерфейс JimpleVisitor, а точнее JimpleBaseVisitor — это также сгене­ри­ро­ванный класс, который реализует интерфейс JimpleVisitor и содержит реали­зации всех методов. Это позволяет переопре­делить только те методы, которые нам нужны.

Реали­зация интерпретатора

Наконец-то мы добрались до самого интересного — реали­зации интер­пре­татора. Хотя в данной статье не будем затра­гивать вопрос проверки кода на ошибки, но ошибки интер­пре­тации все-таки будут реали­зованы. Для начала, создадим класс JimpleInterpreter с методом eval, на вход которого будет подаваться строка с кодом на Jimple. Далее нам нужно разобрать исходный код на токены с помощью JimpleLexer, затем создать дерево AST, используя JimpleParser.

public class JimpleInterpreter {
    public Object eval(final String input) {
        // разбираем исходный код на токены
        final JimpleLexer lexer = new JimpleLexer(CharStreams.fromString(input));
        // создаем дерево AST
        final JimpleParser parser = new JimpleParser(new CommonTokenStream(lexer));
        // создаем объект класса JimpleInterpreterVisitor
        final JimpleInterpreterVisitor interpreterVisitor = new JimpleInterpreterVisitor(new JimpleContextImpl(stdout));
        // запускаем интерпретатор
        return interpreterVisitor.visitProgram(parser.program());
    }
}

У нас есть синтак­си­ческое дерево. Давайте добавим семантики с помощью написанного нами класса JimpleInterpreterVisitor, который будет обходить AST, вызывая соответ­ствующие методы. Так как корневым правилом нашей грамматики является правило program (см. выше program: (statement)* EOF), то обход дерева начинается именно с него. Для этого вызываем реали­зо­ванный по умолчанию метод visitProgram у объекта JimpleInterpreterVisitor, на вход которого даем объект класса ProgramContext. Реали­зация ANTLR состоит из вызова метода visitChildren(RuleNode node), который обходит все дочерние элементы заданного узла дерева, вызывая для каждого из них метод visit.

// код сгенерирован ANTLR
public class JimpleBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements JimpleVisitor<T> {
    @Override public T visitProgram(JimpleParser.ProgramContext ctx) {
        return visitChildren(ctx);
    }

    // другие методы опущены для краткости
}

Как можно заметить, JimpleBaseVisitor — это generic-класс, для которого нужно определить тип обработки каждого узла. В нашем случае это класс Object, так как выражения могут возвращать значения разного типа. Обычно выражение (expression) должно вернуть какое-либо значение, а утвер­ждение (statement) ничего не возвращает. В этом и заклю­чается их различие. В случае утвер­ждения можем возвращать null. Однако, чтобы случайно не столк­нуться с NullPointerException, вместо null будем возвращать объект типа Object, который глобально определен в классе JimpleInterpreter:

public static final Object VOID = new Object();

Класс JimpleInterpreterVisitor расширяет класс JimpleBaseVisitor, переопре­деляя только интере­сующие нас методы. Рассмотрим реали­зацию встро­енного оператора println, который в грамматике описан как println: 'println' expression ;. Первое, что нужно сделать — это вычислить выражение expression, для этого нам нужно вызвать метод visit и передать в него объект expression из текущего контекста PrintlnContext. В методе visitPrintln нас совер­шенно не интересует, как вычис­ляется выражение, за вычис­ление каждого правила (контекста) отвечает соответ­ствующий метод. Например, для вычис­ления строкового литерала исполь­зуется метод visitStringExpr.

public class JimpleInterpreterVisitor extends JimpleBaseVisitor<Object> {
    @Override
    public Object visitPrintln(final JimpleParser.PrintlnContext ctx) {
        final Object result = visit(ctx.expression());
        System.out.println(result);
        return null;
    }

    @Override
    public Object visitStringExpr(final JimpleParser.StringExprContext ctx) {
        // возвращаем строковый литерал
        return cleanStringLiteral(ctx.STRING_LITERAL().getText());
    }

    private String cleanStringLiteral(final String literal) {
        // очистить строку от кавычек
        return literal.length() > 1 ? literal.substring(1, literal.length() - 1) : literal;
    }

    // другие методы опущены для краткости
}

Реали­зовав только лишь эти методы, интер­пре­татор уже поддер­живает println и строковые литералы, что позволяет нам выполнить код println "Hello, Jimple!".

Запуск интер­пре­татора

Для запуска интер­пре­татора нужно создать стандартный метод main, который после небольших проверок, используя класс JimpleInterpreter, будет запускать наш код:

public class MainApp {
    public static void main(String[] args) {
        if (args.length < 1) {
            System.out.println("usage: jimple input.jimple");
            System.exit(1);
        }

        Path path = Paths.get(args[0]);
        if (!Files.exists(path)) {
            System.err.println("File not found: " + path);
            System.exit(1);
        }

        new JimpleInterpreter().eval(path);
    }
}

Детали реали­зации

Приводить весь код реали­зации интер­пре­татора нет необхо­ди­мости, ссылку на исходники можно будет найти в конце статьи. Однако хочу остано­виться на некоторых интересных деталях.

Как уже было упомянуто, интер­пре­татор построен на основе паттерна Visitor, который посещает узлы дерева AST и выполняет соответ­ствующие инструкции. В процессе выпол­нения кода в текущем контексте появляются новые иденти­фи­каторы (имена переменных и/или функций), которые нужно где-то хранить. Для этого напишем класс JimpleContext, который будет хранить не только эти иденти­фи­каторы, но и текущий контекст выпол­нения вложенных блоков кода и функций, так как локальная переменная и/или параметр функции должны быть удалены после выхода из области их видимости.

@Override
public Object handleFunc(FunctionSignature func, List<String> parameters, List<Object> arguments, FunctionDefinitionContext ctx) {
    Map<String, Object> variables = new HashMap<>(parameters.size());
    for (int i = 0; i < parameters.size(); i++) {
    variables.put(parameters.get(i), arguments.get(i));
    }
    // создаем новую область видимости параметров функции и помещаем ее в стек
    context.pushCallScope(variables);

    // выполнение выражений функций опущены для краткости

    // удаляем область видимости параметров функции из стека
    context.popCallScope();

    return functionResult;
}

В нашем языке переменная хранит значение типа, который опреде­ляется во время выпол­нения. Далее, в следующих инструкциях, этот тип может меняться. По сути, у нас получился язык с динами­ческой типизацией. Однако, некоторая проверка типов все же присут­ствует, в случаях когда, выпол­нение операции бессмыс­ленно. Например, число нельзя поделить на строку.

Зачем нужно два прохода?

Перво­на­чальная версия интер­пре­татора заклю­чалась в реали­зации метода для каждого правила. Например, если метод обработки объяв­ления функции находит функцию с таким именем (и количе­ством параметров) в текущем контексте, то выбра­сы­вается исклю­чение, иначе функция добав­ляется в текущий контекст. Таким же образом работает метод вызова функции. Если функция не найдена, то выбра­сы­вается исклю­чение, иначе функция вызывается. Такой подход работает, но он не позволяет вызывать функцию до её опреде­ления. Например, следующий код не будет работать:

var result = 9 + 10

println "Result is " + add(result, 34)

fun add(a, b) {
return a + b
}

В данном случае, у нас есть два подхода. Первый — это требовать определять функции до её исполь­зо­вания (не очень удобно для пользо­ва­телей языка). Второй — выполнять два прохода. Первый проход нужен для того, чтобы найти все функции, которые были определены в коде. А второй — непосред­ственно для выпол­нения кода. В своей реали­зации выбрал второй подход. Следует перенести реали­зацию метода visitFunctionDefinition в отдельный класс, который расширяет уже известный нам сгене­ри­ро­ванный класс JimpleBaseVisitor<T>.

// класс находит все функции в коде и регистрирует их в контексте
public class FunctionDefinitionVisitor extends JimpleBaseVisitor<Object> {
    private final JimpleContext context;
    private final FunctionCallHandler handler;

    public FunctionDefinitionVisitor(final JimpleContext context, final FunctionCallHandler handler) {
        this.context = context;
        this.handler = handler;
    }

    @Override
    public Object visitFunctionDefinition(final JimpleParser.FunctionDefinitionContext ctx) {
        final String name = ctx.name.getText();
        final List<String> parameters = ctx.IDENTIFIER().stream().skip(1).map(ParseTree::getText).toList();
        final var funcSig = new FunctionSignature(name, parameters.size());
        context.registerFunction(funcSig, (func, args) -> handler.handleFunc(func, parameters, args, ctx));
        return VOID;
    }
}

Теперь у нас есть класс, который можем исполь­зовать перед непосред­ственным запуском класса интер­пре­татора. Он будет наполнять наш контекст опреде­ле­ниями всех функций, которые будем вызывать в классе интерпретатора.

Как выглядит AST?

Для того, чтобы визуа­ли­зи­ровать AST, нужно восполь­зо­ваться утилитой grun (см. выше). Для этого следует запустить grun с параметрами Jimple program -gui (первый параметр – имя грамматики, второй – имя правила). В результате откроется окно с деревом AST. Перед выпол­нением этой утилиты важно скомпи­ли­ровать сгене­ри­ро­ванный ANTLR-ом код.

# сгенерировать парсер
antlr4.sh Jimple.g4
# скомпилировать сгенерированный код
javac -cp ".:/usr/lib/jvm/antlr-4.12.0-complete.jar" Jimple*.java
# запустить grun
grun.sh Jimple program -gui
# ввести код: "println "Hello, Jimple!"
# нажать Ctrl+D (Linux) или Ctrl+Z (Windows)

Для Jimple-кода println "Hello, Jimple!" сгене­ри­руется следующее AST:

 

Резюме

В данной статье, вы позна­ко­мились с таким понятиями как лекси­ческий и синтак­си­ческий анали­заторы. Исполь­зовали инструмент ANTLR для генерации таких анали­за­торов. Узнали, как писать грамматику ANTLR. В итоге смогли создать простой язык, а именно разра­ботали интер­пре­татор для него. В качестве бонуса смогли визуа­ли­зи­ровать AST.

Весь исходный код интер­пре­татора можно посмотреть по ссылке.

Ссылки

Действующие лица

web.sta – web-прило­жение, целевой браузер Internet Explorer версии 9 и 11
TextSystem – standalone прило­жение, написанное на Java/Swing
IntermediateLayer – Client-Server прило­жение, которое представляет собой связующее звено между web.sta и TextSystem. 

web.sta может вызвать TextSystem через IntermediateLayer. Для этого IntermediateLayer создаёт http server и web-прило­жение web.sta отправляет GET или POST запросы на localhost. Там IntermediateLayer обраба­тывает параметры запроса и стартует TextSystem. Обратной связи не требуется, поэтому схема взаимо­дей­ствия проста и доста­точно надёжна.

Citrix

В окружении Citrix мы не можем создать несколько слушающих сокетов на одном IP адресе и порте. Тут или надо менять порт, или же менять IP адрес. Но Citrix предо­ставляет специ­альный механизм для обхода этого ограни­чения, без изменения алгоритма работы программы. Этот механизм называется “Virtual IP Loopback”. Админи­стратору доста­точно указать необхо­димые прило­жения в панели конфи­гу­ри­ро­вания Citrix и прило­жение, которое использует localhost для сокетных соеди­нений, будет получать не 127.0.0.1, а IP адрес вида 127.0.0.<SID+1>, где SID это иденти­фи­катор сессии пользо­вателя Windows.

Проблема

Всё это прекрасно работало под IE9 (да и с другими браузерами тоже) на WindowsServer2008 R2. А потом клиентам захотелось чего-то нового и появился WindowsServer2012 R2 c IE11. И вся система перестала работать. Независимо от настроек Citrix IE11 всегда, при указании localhost, пытается установить соеди­нение на 127.0.0.1, а там никто не слушает. После небольшого иссле­до­вания мы пришли к выводу, что это баг именно в IE11.

RoutingService

Если вирту­а­ли­зация для localhost не работает из коробки Citrix для IE11, то давайте напишем её сами! Для этих целей мы решили написать windows service, который будет простейшим web сервером, слушать на 127.0.0.1 и перена­правлять запросы на нужные инстансы IntermediateLayer, основы­ваясь на номере сессии пользо­вателя. Простого решения получить SID мы не нашли, но в переменных окружения мы сразу обнаружили SESSIONNAME. В IE через ActiveX мы получаем переменную окружения, передаем её в качестве параметра в http запрос, в RoutingService по имени сессии мы через wtsapi32.lib получаем номер сессии. А затем перена­правляем http запрос и соответ­ственно возвращаем ответ.

Что-то пошло не так

Начали тести­ровать и внедрять наш сервис. Но не всё оказалось так радужно, как хотелось бы. Как оказалось имя сессии может меняться, правда мы так и не поняли в каких условиях это проис­ходит, а разби­раться ещё и с этим, как всегда не хватало времени. Но часто случалось, что имя сессии изменилось, а IE11 знает только перво­на­чальное значение переменной окружения. И упорно передаёт это значение на RoutingService.

А что там в реестре?

Надо найти другой способ получать sessionname. Поискали инфор­мацию о сессиях в реестре и вот что нашли: в HKEY_CURRENT_USER\Volatile Environment можно получить список сессий текущего пользователя.

Если сессия пользо­вателя одна – то вообще всё прекрасно, прочитали и используем. А если сессий одного пользо­вателя много, то надо как-то определить, в какой же мы сессии находимся. Ничего лучше, чем сопоставить путь к папке временных файлов, мы не придумали.

Вот пример:

В IE получаем текущий путь к TEMP, используя ActiveX Scripting.FileSystemObject. Таким образом нам удалось получить имя нашей сессии. Но это не всё. Значения ключей под Volatile Environment – это, собственно, SID и есть. То есть мы можем сразу в JavaScript получить необхо­димый IP адрес и оправить запрос на него.

Будем упрощать? 

В итоге мы можем получить SID и устанав­ливать соеди­нение напрямую, без исполь­зо­вания RoutingService. Но решение всё равно не выглядит красивым. Изучение интернета показало, что проблема есть, но решение этой проблемы нигде не описы­вается. А сам Microsoft молчит.

Так что, возможно, кому-то наш опыт будет полезен в решении этой специ­фичной проблемы.