Как стать автором
Обновить

Комментарии 29

И вообще, зачем вам такие сложности? Возьмите нормальный простой рекурсивный парзер!

Понимаешь ANLR это не Рембо- пришел увидел, победил.
ANLR это целая мобильная бригада, которая строит аванпост от которого вздрагивают все новые разработчики, но это чертов аванпост может быть и в горах и в лесах и даже посреди океана без тверди землей одна колючая проволока и процедуры входа-выхода. А как и любой бригадой ей надо уметь управлять.

Упаковывание {} в один токен? И бывало такое?

Делаешь токен с новым типом, сохраняешь в него список токенов, регистрируешь каким-то образом токен, что бы можно было узнать о нем. Далее в местах, где есть блок, добавляешь альтернативу с новым типом токена. После анализа файла создаешь парсеры для сохраненных токенов и разбираешь их, пока не останется в регистре элементов. Лексер работает только один раз.
И вуаля, вместо 4 ГБ хипа хватает 128 мб.

Если вас не затруднит, можно пример с кодом в g4? Сейчас стоит задача дополнительного разбора комментариев к методам, думал идти по пути создания второго "лёгкого" лексера, выбрасывающего лишние токены через skip, но судя по вашему комментарию это можно сделать быстрее/дешевле в рамках одного лексера.

У лексера можно переопределить обработку токенов:


  override def nextToken(): Token =
    if (hasEnqueuedTokens) dequeueToken
    else if (templateMode) nextTokenTemplate
    else nextTokenNonTemplate

  private def nextTokenNonTemplate: Token = {
    val token = _input.LA(1) match {
      case '`' => parseTemplateLiteral
      case _ => super.nextToken()
    }
    if (token.getChannel == Token.DEFAULT_CHANNEL)
      lastToken = token
    token
  }

  private def nextTokenTemplate: Token =
    if (!insideTemplate) {
      if (_input.LA(1) != '`')
        throw new IllegalStateException("Template string must start with '`' character")
      insideTemplate = true
      val token = createToken(_input.index(), 1, templateLiteralDelimiter, Token.HIDDEN_CHANNEL)
      templateStartToken()
      token
    } else if (_input.index() == closePosition) {
      if (_input.LA(1) != '}')
        throw new IllegalStateException("Close position is not at '}' character")
      val token = createToken(_input.index(), 1, templateLiteralDelimiter, Token.HIDDEN_CHANNEL)
      templateMiddleOrStopToken()
      token
    } else nextTokenNonTemplate

Это кусок кода для анализа TypeScript/JavaScript, гораздо выгоднее проанализировать собственными руками последовательность символов и сделать токен с шаблонной строкой, чем нагружать лексер, тем более, что в случае вложенности будут проблемы с анализом выражений. Для правила startTemplate токены TemplateLiteral* создавались не лексером, а руками. Код у правил удалил, потому что при просмотре превращается в кашу.


//////////////////////////////////////// Main Entry Points 
startTypeScript
    :   typeScriptStmtList EOF
    |   EOF
    ;

//////////////////////////////////////// Secondary Entry Points 
startTemplate
    :  
        TemplateLiteralStart
        // опционально, потому что TemplateLiteralStart может содержать окончание
        (
            expression[true,_yield,await] 
            (
                TemplateLiteralMiddle                
                expression[true,_yield,await] 
            )*
            TemplateLiteralStop
        )?
        EOF        
    ;

  private def parseTemplateLiteral: Token = {
    val start = _input.index()
    createToken(start, parseTemplateString(), templateLiteral)
  }

parseTemplateString — находит индекс последнего символа шаблонной строки с учетом вложенности.
Уже после этот литерал парсится отдельно. Например вот так:


    final def parse(): RLangOp = {
      val result = parseInitial()
      enqueue(result.folds ++ result.templates)
      while (jobs.nonEmpty) {
        val _j = Seq.empty ++ jobs
        jobs.clear()
        parseItems(_j)
      }
      result.op.asInstanceOf[RLangOp]
    }

Внутри parseItems все folds, templates так же добавляются в очередь jobs
Полагаю, что с комментариями можно такую же штуку провернуть.
Должно быть что-то в таком духе у вас:


methodWithMeta:
  MethodMeta?
  methodDecl[$MethodMeta]

Если поиграетесь с режимами работы лексера (mode — его можно устанавливать до выполнения), то можно все в одном уместить, и с парсером так же удастся сделать.


Правила можно легко вызывать вот таким образом:


  final def invokeRule[T](ruleName: String): T = resultOfRule[T](ruleName)

  private def resultOfRule[T](ruleName: String): T =
    resultField[T](getClass.getDeclaredMethod(ruleName).invoke(this))

  private def resultField[T](value: AnyRef): T =
    value.getClass.getDeclaredField("result").get(value).asInstanceOf[T]

Не вижу смысла использовать команду skip, выкидывать лишние токены, а тем более использовать двойной проход. Лучше помещать их в скрытый, либо какой-то кастомный канал -> channel(HIDDEN), подробней — в официально документации. В этом канале они не будут мешать парсеру, но будут доступны для обработки. Команду skip я бы использовал только в случае, если текста очень много и даже токены будут занимать много места в памяти.


Нужно ли связывать комментариями с узлами дерева разбора? Если нет — все просто, я по сути уже ответил на вопрос. Если да, то рекомендую использовать систему лидирующих (leading) и хвостовых скрытых токенов (trailing). Такое используется в Roslyn, посмотреть можно здесь: Теория и практика парсинга формальных языков. Это можно реализовать и в рамках ANTLR (реализовано в коммерческом проекте, но планирую заопенсорсить как руки дойдут).

Имхо, если статья для начинающих антлеровщиков, то они мало что поймут. Если для опытных, то они и так практически все это знают.


Для каждого пункта было бы неплохо добавить разъяснение и примеры кода как не надо и как надо (или наоборот, т.к. советы вредные). Особенно про ATN и SLL.


ATN — внутренний кэш, который заполняется в процессе парсинга файлов. Он нужен для увеличения производительности парсинга последующих файлов. Его можно обнулять после обработки определенного количества файлов, либо символов. Так сделано, например, здесь. Также можно почитать про очистку памяти в статье Теория и практика парсинга исходников с помощью ANTLR и Roslyn, но статья немного устаревшая и в ней описан старый подход.


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


Максимизация скорости парсинга в ANTLR
// try with simpler/faster SLL(*)
parser.getInterpreter().setPredictionMode(PredictionMode.SLL);
// we don't want error messages or recovery during first try
parser.removeErrorListeners();
parser.setErrorHandler(new BailErrorStrategy());
try {
    parser.startRule();
    // if we get here, there was no syntax error and SLL(*) was enough;
    // there is no need to try full LL(*)
}
catch (ParseCancellationException ex) { // thrown by BailErrorStrategy
    tokens.reset(); // rewind input stream
    parser.reset();
    // back to standard listeners/handlers
    parser.addErrorListener(ConsoleErrorListener.INSTANCE);
    parser.setErrorHandler(new DefaultErrorStrategy());
    // full now with full LL(*)
    parser.getInterpreter().setPredictionMode(PredictionMode.LL);
    parser.startRule();
}

Внедрение кода в грамматику не только лишние трудности, но и потенциальная проблема при рефакторинге!

Можно поспорить насчет вредности этого совета, поскольку внедрение кода — это, как правило, заточка грамматики под конкретный рантайм. Хотелось бы делать максимально универсальную грамматику, чтобы больше пользователей могло ее использовать и править баги. Поэтому мы (разработчики из Positive Technologies) стараемся обходиться без вставок кода, либо же унифицировать их при помощи base или super классов. Так сделано, например, в грамматике JavaScript.


Докину вредный совет от себя, который связан с 5:


Поэтапная обработка — наше все


Вам всегда нужно генерировать дерева разбора без использования опции BuildParseTree. И только после этого запускать листенеры или визиторы на получившемся дереве. Даже если задача — простой подсчет количества деклараций методов. Все должно быть поэтапно: парсинг, обработка, а не в одной куче. Да и чем больше памяти ест программа — тем солидней, ведь это значит, что она выполняет тяжелые и сложные вычисления, а об этом не стыдно рассказать и заказчику.

Увы, но предложенное вами решение крайне медленное. Сброс кеша в джаве таким же способом не удастся реализовать, особенно, если есть многопоточный анализ. Возможно в C# как-то по другому реализованы статики.
Дано: грамматика JavaScript 2019, на вход посылаем файл в 127 кб минифицированного кода — 38 секунд на обработку, 12% gc max. Без xmx4g запускать бессысленно.
Включаем упаковку блоков в токен (создается токен, в котором есть список токенов) — 12 секунд xmx64m дает 5% gc max, при xmx128m 1% gc max, парсим блоки в многопоточном режиме на 8 ядрах — 4 секунды. ATN создается на каждый новый инстанс парсера.


Поэтапная обработка — наше все

Если под поэтапностью вы понимаете разбор одного и того же файла разными парсерами, то да, но использовать AST, создаваемый ANTLR все равно не рекомендую, слишком много лишней информации.


parser.getInterpreter().setPredictionMode(PredictionMode.LL);

Java, CSharp, Python, Php, JavaScript, TypeScript, C++, C, и многие другие языки достаточно просты, что бы сделать грамматику, которая будет работать без синтаксических ошибок в режиме SLL.
Вы что-то не так делаете в вашем парсере, что требуется включать такую фичу.


Впрочем я уже видел работу анализатора Positive Tech :D Воздержусь от критики.


Так сделано, например, в грамматике JavaScript.

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


Для каждого пункта было бы неплохо добавить разъяснение и примеры кода как не надо и как надо (или наоборот, т.к. советы вредные). Особенно про ATN и SLL.

Будет отдельная статья, где объясню всю кухню работы с ANTLR4 на Java, данная статья была больше для того, что бы понять, есть ли интерес у людей к такой тематике.

Увы, но предложенное вами решение крайне медленное. Сброс кеша в джаве таким же способом не удастся реализовать, особенно, если есть многопоточный анализ. Возможно в C# как-то по другому реализованы статики.

Каким образом, просто обнулять ссылки медленно?


Включаем упаковку блоков в токен (создается токен, в котором есть список токенов)

Как это работает, как парсится. Можно поподробней?


Если под поэтапностью вы понимаете разбор одного и того же файла разными парсерами, то да, но использовать AST, создаваемый ANTLR все равно не рекомендую, слишком много лишней информации.

Я имею в виду, что строить дефолтное дерево разбора ANTLR — совсем не обязательно, т.к. оно может занимать много места и хранит лишнюю информацию. Можно обрабатывать узлы сразу во время парсинга, установив флаг BuildParseTree = false. Например, строить свое представление, выкидывать лишние узлы, примерно как здесь. Правда это восходящая обработка, и она более сложная. С помощью такой оптимизации удалось сократить потребление памяти в 2.5 раза и увеличить производительность на парсинге 30 Мб PL/SQL файла.


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

В основном этот режим и работает, изредка переключается на LL. Зато это облегчает разработку грамматик.


Впрочем я уже видел работу анализатора Positive Tech :D Воздержусь от критики.

А какие языки? :)


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

В чем посредственность на конкретных примерах? Было бы интересно узнать, как это можно избежать. Ну и для этого они и открытые, чтобы сообщество могло их дорабатывать.


Будет отдельная статья, где объясню всю кухню работы с ANTLR4 на Java, данная статья была больше для того, что бы понять, есть ли интерес у людей к такой тематике.

Интерес есть, пишите. У меня тоже есть идеи, которыми можно поделиться.

Каким образом, просто обнулять ссылки медленно?

Неправильно выразился, парсер, если ему отдавать сразу все работает очень медленно.
А заменить в джаве final static нельзя (на самом деле можно, но это будет слабоумие и отвага).


Как это работает, как парсится. Можно поподробней?

  override def nextToken(): Token = {
    if (hasEnqueuedTokens) dequeueToken
    else super.nextToken() match {
      case x: RScanToken if x.getType == foldOpen => buildFoldToken(x)
      case x: Token => x
    }
  }

buildFoldToken рекурсивно собирает все токены начиная с { и по }, создает для них токен с адресом от { по }, внутри имеет список токенов, которые он поглотил, включая такие же фолды.
А уже в ANTLR идет так:


functionBody[boolean yield, boolean await]
    :   LBRACE stmtList[$yield, $await, true] RBRACE
    |   FoldBlock
    ;

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


А какие языки? :)

Java.


В чем посредственность на конкретных примерах? Было бы интересно узнать, как это можно избежать. Ну и для этого они и открытые, чтобы сообщество могло их дорабатывать.

Они не SLL. Запускаешь эти грамматики на тестовом множестве (2000+ файлов), так половина минимум падает с ошибками.

А заменить в джаве final static нельзя (на самом деле можно, но это будет слабоумие и отвага).

Разве нельзя инициализировать парсер так?


parser.Interpreter = new ParserATNSimulator(parser, GetOrCreateAtn(ParserSerializedATN));

...

protected ATN GetOrCreateAtn(string atnText)
{
    lock (Atns)
    {
        if (!Atns.TryGetValue(Language, out atn))
        {
            atn = new ATNDeserializer().Deserialize(atnText.ToCharArray());
            Atns.Add(Language, atn);
        }
    }

    return atn;
}

А при достижении лимита по памяти, количеству файлов, либо чему-то еще очищать ATN таким образом:


public static void HandleMemoryConsumption()
{
    lock (Atns)
    {
        Atns.Clear();
    }
}

Работать это должно быстро и потокобезопасно.


Java.

На движке абстрактной интерпретации? Когда это было? Там ANTLR и не используется :)


Они не SLL.

А как парсить файлы, в которых есть синтаксические ошибки и их нужно максимально корректно обрабатывать? Т.е. либо добавлять токен, либо удалять токен, либо пропускать последовательность до синхронизирующего токена (типа точки с запятой). Я не пробовал как работает SLL со стандартной стратегией обработки ошибок.

Разве нельзя инициализировать парсер так?

В Java можно это сделать, но проще пропатчить генерируемый класс, чем везде выставлять такую штуку, тем более что в C# достаточно 2 аргументов, а в Java их надо 4. Впрочем попробую, может оно и в правду лучше работать будет.


На движке абстрактной интерпретации? Когда это было? Там ANTLR и не используется :)

Летом отчет по вебгоату присылали. Версию я не помню, осилить HASP ваш не смог :D


А как парсить файлы, в которых есть синтаксические ошибки и их нужно максимально корректно обрабатывать? Т.е. либо добавлять токен, либо удалять токен, либо пропускать последовательность до синхронизирующего токена (типа точки с запятой). Я не пробовал как работает SLL со стандартной стратегией обработки ошибок.

А зачем парсить файлы с синтаксическими ошибками?
Если это какая-то мелкая ошибка, то тем более фолдинг — лучшее решение, потому что потеряется только фрагмент, хотя достаточно требовать, что бы код компилировался родной утилитой, тогда проблем не будет.
SLL хорошо работает на компилируемых файлах. Мусор отправлять на анализ тоже такое себе дело.
Или у вас парсер позволяет в txt файле java-код или какой-нибудь еще?

В Java можно это сделать, но проще пропатчить генерируемый класс, чем везде выставлять такую штуку, тем более что в C# достаточно 2 аргументов, а в Java их надо 4.

Кажется в другом проекте я тоже делал подобным образом. Просто добавлял такой конструктор в грамматику:


@parser::members
{public ObjectiveCParser(ITokenStream input, ATN atn)
    : base(input)
{
    _interp = new ParserATNSimulator(this, atn);
}}

Наверное имеет смысл завести issue в официальном репозитории ANTLR, чтобы такой конструктор добавлялся при генерации парсера.


Кстати, по поводу очистки ATN. Сам создатель оптимизированного ANTLR C# рантайма рекомендует переинициализировать ATN, если нужно снизить потребление памяти.


Если это какая-то мелкая ошибка, то тем более фолдинг — лучшее решение, потому что потеряется только фрагмент, хотя достаточно требовать, что бы код компилировался родной утилитой, тогда проблем не будет.

Интересный подход, надо будет получше изучить его.


А зачем парсить файлы с синтаксическими ошибками?
Мусор отправлять на анализ тоже такое себе дело.

Как минимум для анализаторов в IDE. Они обязаны работать даже если пользователь что-то не дописал, да даже если вставил произвольный txt. Roslyn же справляется — всегда строит достоверное дерево для любого текста.


Или у вас парсер позволяет в txt файле java-код или какой-нибудь еще?

Были реальные исходники, где файлы внезапно обрывались, из-за этого SLL переключался на медленный LL, что приводило к тормозам. Ну и да — есть требование в идеале парсить код с любыми ошибками, включая синтаксические.

Кстати, по поводу очистки ATN. Сам создатель оптимизированного ANTLR C# рантайма рекомендует переинициализировать ATN, если нужно снизить потребление памяти.

Основное потребление памяти из-за необходимости парсера делать слишком долгие lookahead, именно из-за него получаются хипы на 4 ГБ забитые под завязку. С ATN память будет медленно течь, особую проблему быстродействия не доставляет.


Были реальные исходники, где файлы внезапно обрывались, из-за этого SLL переключался на медленный LL, что приводило к тормозам. Ну и да — есть требование в идеале парсить код с любыми ошибками, включая синтаксические.

вы все равно в этом коде ничего не сможете получить, зачем тогда парсить?
В случае синтаксических ошибок в SLL режиме лучше делать сводку о причинах и просить пользователя отправить сообщение разработчику.
В LL режиме нет смысла, разве что вы пишете IDE и неплохо было бы давать адекватную подсветку каждый раз.

вы все равно в этом коде ничего не сможете получить, зачем тогда парсить?

Ну почему, можно получить какие-то уязвимости и недостатки методом pattern matching — иногда даже этого хватает. А может даже taint анализом. Иногда лучше находить хоть что-то, чем падать. Обратная сторона — это когда требуется полная компилируемость, а такое не всегда легко достигается, особенно если используется C++.


В любом случае, с нетерпением буду ждать вашу статью по ANTLR. Когда планируется? :)

речь не про "падать", а про игнорирование файла.


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


особенно если используется C++.

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

Java, CSharp, Python, Php, JavaScript, TypeScript, C++, C, и многие другие языки достаточно просты, что бы сделать грамматику, которая будет работать без синтаксических ошибок в режиме SLL.

Лихо вы C++ в список простых языков закинули. С учетом темплейтов он вообще тюринг-полный для парсинга. Кроме того, в нем есть проблемы типа Lexer hack.


А есть ли у вас какой-нибудь опыт по обработке препроцессорных директив? Больше всего меня интересует маппинг локаций между узлам деревьев до препроцессорной обработки и после. Или маппинг на исходные локации в тексте.

Не увидел чего-то сложного перейдя по вашей ссылке.
То что понять что есть что на уровне лексера не выйдет, да и у парсера будут проблемы — да.
Но зачем идти по извилистой земляной дорожке в поле, если рядом есть шоссе?


Советую попробовать другие варианты работы с исходными кодами. Особенно это касается AST, он вам не нужен. UST — ближе, но все еще не то.


А есть ли у вас какой-нибудь опыт по обработке препроцессорных директив?

Писал, правда 5 дней было потрачено на него и, как говорил Костя, пару багов исправил, уже после моего увольнения. Соответствие спецификации 2011 года — 100%, но есть там нюансы со склейкой токенов, видимо на них баги и были.
Есть небольшое расхождение в части переноса строк, в GNU они уходят, в спеке говорится, что должны оставаться. Я про макросы и параметры.


Есть два способа — делать сложные стейтменты (когда у стейтмента может быть предок), либо же результаты макро-обработки мэппить на координаты исходного токена (или токенов). Что бы избежать наложения адресов советую добавить virtual — тогда каждый новый токен будет иметь свой адрес при любых раскладах. Указывать будет на точку применения макроса.


Вообще это отдельная тема и обсуждать ее тут не вижу смысла.

Особенно это касается AST, он вам не нужен. UST — ближе, но все еще не то.

Довольно голословное утверждение. Раньше считал, что UST это не совсем то, а нужны другие графовые представление (DFG, CFG). Сейчас считаю, что UST + семантическиая модель достаточны для реализации быстрого Taint анализа без учета условий достижимости и минификации дублирования в коде.


Впрочем, это тема тем более для другого топика.

Вспомнил еще один вредный совет:


Семантические предикаты всегда перед лексемами


Так грамматика всегда радует глаз и не важно, что скорость лексера при этом катастрофически падает!


(Разъяснение здесь)

Кстати, обнаружил эту ошибку и у вас в лексере. Раз уж гонитесь за скоростью, то попробуйте переместить эти предикаты в конец.

Кстати, обнаружил эту ошибку и у вас в лексере. Раз уж гонитесь за скоростью, то попробуйте переместить эти предикаты в конец.

Тест токенизации файла размером 1,7 мб:
Как было — 5615 ms
Перенес за символ / предикат — 5440 ms


Небольшой выигрыш оказался :D

3% вообще это серьёзно (Если, конечно, статистически значимая разница).

Статистику не собирал, слишком долго. Но в районе 2-3% должно дать, да. Правда есть одно но: изначально говорил, что еще можно 30-40% оптимизировать без злобных хаков то, что выложил =)
Ищите и думайте =) Итак уже безвозмездно выложил оптимизацию в 2-10 раз.

Ну вообще несерьезно :) Просто у меня была намного большая разница в php и еще где-то — возможно неправильно в свое время реализовал сами семантические предикаты, либо в JavaScript грамматике этот момент не сильно влияет. Но все равно совет имеет место быть — лексер пытается сматчить сразу все токены, использует специальный автомат, а предикаты препятствуют этому. К тому же не только я сталкивался с этим, что доказывает вышеприведенная ссылка, в которой автор C++ ANTLR рантайма пишет:


Predicates on the left hand side of a rule prevent the DFA generated for it not to be cached, but cause them to be evaluated again and again. This is especially bad if you have many alts, e.g. many keywords (lexer rules are kinda alts of a hidden root context).

В парсере предикаты лучше использовать как можно реже, потому что они генерируют исключения:


if (!(isVersion12())) throw new FailedPredicateException(this, "isVersion12()");

Есть такое, но для регулярных выражений очень сложная семантика, поэтому выгоднее сделать простой валидатор, что бы не запускать процесс обработки вовсе, чем добавлять в кеш эту штуку.
Просто для примера, неправильно позиционированный предикат для регулярок JS заставляет в кэш попадать строки по 20-30 тысяч символов, просто потому что регулярка так парсится =) Это случается редко и скорее всего лишь во время жертвоприношения девственниц, но бабахает больно. Поэтому и не стал добавлять в список вредных советов ваш вариант, так как он ситуативен, даже несмотря на приведенный вами комментарий про DFA. Как уже говорил — DFA работает лишь в определенных границах, вне его пределов — превращается в шлак.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации