Что делать, если для вашего любимого языка нет статического анализатора?

в 7:55, , рубрики: copypaste, cpd, java, modelica, open source, pmd, static analysis, копипаст, Программирование, Проектирование и рефакторинг, статический анализ

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

Кстати, знаете, что может ухудшить качество кодовой базы, полученной из последовательности идеальных pull request-ов? Тот факт, что сторонние программисты копировали в свои патчи куски существующего кода проекта вместо грамотного абстрагирования. Согласитесь, в какой-то мере такую банальность отловить ещё сложнее, чем некачественный код — он же качественный и даже уже тщательно отлаженный, поэтому тут недостаточно локальной проверки, нужно держать в голове всю кодовую базу, а человеку это непросто… Так вот: если на добавление полной поддержки Modelica (без создания конкретных правил) до состояния «может запускать примитивные проверки» у меня ушло около недели, то поддержку только copy-paste detector часто можно вообще добавить за день!

Какая ещё Modelica?

Modelica — это, как можно догадаться из названия, язык для написания моделей физических систем. На самом деле, не только физических: можно описывать химические процессы, количественное поведение популяций животных и т. д. — то, что описывается системами дифференциальных уравнений вида der(X) = f(X), где X — вектор неизвестных. Императивные куски кода тоже поддерживаются. Уравнения в частных производных в явном виде не поддерживаются, но можно разбить исследуемую область на кусочки (как мы бы, наверное, и делали на каком-нибудь языке общего назначения), а потом записать уравнения для каждого элемента, сведя задачу к предыдущей. Прикол же Моделики в том, что решение этого самого der(X) = f(X) ложится на компилятор: вы можете просто поменять солвер в настройках, уравнение не обязано быть линейным и т. д. Короче говоря, есть как свои плюсы (выписал формулу из учебника — и оно заработало), так и минусы (с большей абстракцией мы получаем меньший контроль). Введение в Моделику — тема отдельной статьи (которые уже на Хабре несколько раз появлялись), а то и целого цикла, сегодня же она меня интересует как открытый и имеющий несколько реализаций, но, увы, всё ещё сыроватый стандарт.

К тому же, Моделика, с одной стороны, имеет статическую типизацию (что нам поможет быстрее написать какой-нибудь осмысленный анализ), с другой, инстанциируя модель, компилятор не обязан полностью проверять всю библиотеку (поэтому статический анализатор весьма полезен для отлова «спящих» багов). Наконец, в отличие от какого-нибудь C++, для которого существует туча статических анализаторов и компиляторов с прекрасной, а главное развёрнутой, см. C++ templates диагностикой ошибок, компиляторы Моделики периодически всё же выдают Internal compiler error, а значит, есть где помочь пользователю даже с достаточно простым анализатором.

А что за PMD?

Я отвечу песней байкой. Когда-то я хотел сделать какой-то небольшой pull request в среду разработки для OpenModelica. Увидев, как в другой части кода обрабатывается сохранение модели, я заметил маленький и не очень понятный внутри кусок кода строчек из четырёх, который поддерживал какой-то инвариант. Не поняв, с какими именно внутренностями редактора он взаимодействует, но осознав, что с точки зрения этого кусочка кода моя задача полностью идентична, я просто вынес его в функцию, чтобы переиспользовать у себя и не сломать. Ментейнер сказал, мол, замечательно, только тогда уж замени этот код на вызов функции и в остальных двадцати местах… Я решил пока не связываться, и просто сделал ещё одну копию, отметив, что потом как-нибудь нужно будет сразу всё причесать, не смешивая с текущим патчем. Погуглив, я нашёл Copy-paste Detector (CPD) — часть статического анализатора PMD — который поддерживает ещё больше языков, чем сам анализатор. Натравив его на кодовую базу OMEdit, я ожидал увидеть те два десятка кусочков из четырёх строк. Их-то я как раз не увидел (каждый из них просто по количеству токенов не превысил порог), зато увидел, например, повторение чуть ли не на полсотни строк C++ кода. Как я уже говорил, вряд ли ментейнер так запросто скопипастил гигантский кусок из другого файла. А вот пропустить такое в PR он мог запросто — ведь код по определению уже соответствовал всем стандартам проекта! Когда я поделился наблюдением с ментейнером, он согласился, что нужно будет прибраться в рамках отдельной задачи.

Соответственно, Program Mistake Detector (PMD) — это легко расширяемый статический анализатор. Может, множества значений, которые может принимать переменная, он и не вычисляет (хотя кто его знает...), но для добавления в него правил даже не нужно знать Java и вообще как-то менять его код! Дело в том, что первым делом он, что и неудивительно, строит AST файлов с исходниками. А на что похоже дерево разбора исходника? На дерево разбора XML! Значит, можно описывать правила просто как XPath запросы — на что совпало, на то и выдаём предупреждение. У них даже графический отладчик для правил есть! Более сложные правила, конечно, можно писать прямо на Java в виде visitor-ов для AST.

Следствие: PMD можно использовать не только для суровых и универсальных правил, которые суровые Java-программисты закоммитили в код анализатора, но и для местных coding style — хоть в каждый репозиторий свой собственный местный ruleset.xml загоняй!

Уровень 1: находим копипаст автоматизированно

В принципе, добавить поддержку нового языка в CPD часто очень просто. Дословно пересказывать документацию «как делать» смысла не вижу — она очень понятная, структурированная и пошаговая. Такую пересказывать — только в испорченный телефон играть. Лучше опишу, что вас при этом ждёт (TLDR: ничего страшного):

  • Разработка анализатора (и PMD, и CPD) ведётся на Гитхабе в репозитории pmd/pmd
  • Визуальный отладчик правил вынесен в отдельный репозиторий pmd/pmd-designer. Обратите внимание, что готовый jar-ник автоматически подкладывается в PMD binary distribution, который для вас соберёт Gradle в предыдущем репозитории, специально клонировать pmd-designer для этого не требуется.
  • Проект имеет Developer Documentation. Та, которую я читал, была весьма подробна. Правда, чуточку устаревшая, но это лечится вторым pull request-ом :)

Сразу предупрежу, что разработку я веду на Ubuntu. На Windows оно тоже должно работать отлично — и в смысле качества, и в смысле незначительно отличающегося способа запуска тулов.

Итак, чтобы добавить новый язык в CPD, нужно всего лишь...

  • ВНИМАНИЕ: если вы хотите полную поддерку PMD до релиза PMD 7, то лучше сразу перейдите к уровню 2, поскольку нормальная поддержка простого пути через готовую Antlr-грамматику появится, по слухам, в той самой версии 7, а пока вы просто потратите время (хотя и чуть чуть...)
  • Форкнуть репозиторий pmd/pmd.
  • Найти в antlr/grammars-v4 уже готовую грамматику для вашего языка — конечно, если язык внутренний, её придётся написать самостоятельно, но для Моделики, например, она нашлась. Тут, конечно, нужно соблюсти формальности с лицензиями — я не юрист, но, как минимум, нужно указать источник, откуда скопировали.
  • После этого нужно создать модуль pmd-<your language name>, добавить его в Gradle и положить туда файл грамматики. Далее, прочитав две странички ненапряжной документации, переделать из модуля для Go сборочный скрипт, парочку классов для загрузки модуля через рефлексию ну и там по мелочи...
  • Поправить эталонный вывод в одном из тестов, ведь теперь CPD поддерживает ещё один язык! Как вы найдёте этот тест? Очень легко: он захочет сломать билд.
  • PROFIT! Это реально просто при условии, что есть готовая грамматика

Теперь, находясь в корне репозитория pmd, вы можете набрать ./mvnw clean verify, при этом в pmd-dist/target вы получите среди прочего binary distribution в виде zip-архива, который нужно распаковать и запустить с помощью ./bin/run.sh cpd --minimum-tokens 100 --files /path/to/source/dir --language <your language name> из распакованного каталога. В принципе, можно выполнить ../mvnw clean verify изнутри вашего нового модуля, что радикально ускорит сборку, но потом придётся правильно подложить собранный jar-ник в распакованный binary distribution (например, собранный единожды после регистрации нового модуля).

Уровень 2: находим ошибки и нарушения style guide

Как я уже говорил, полную поддержку Antlr обещают в PMD 7. Если же вам, как и мне, не хочется ждать у моря релиза, то придётся откуда-то получить описание грамматики языка в формате JJTree. Может, можно и самим накостылить поддержку произвольного парсера — в документации говорят, что это возможно, но не рассказывают, как именно… Я же просто взял за основу modelica.g4 из всё того же репозитория с грамматиками для Anltr, и вручную переделал в JJTree. Естественно, если грамматика получилась переработкой существующей, опять же, укажите источник, проверьте соблюдение лицензий и. т. д.

Кстати, для человека, хорошо разбирающегося во всевозможных генераторах парсеров, это вряд ли станет сюрпризом. Я же до этого «всерьёз» пользовался разве что собственноручно написанными регулярками и parser combinator-ами на Scala. Поэтому очевидная, в сущности, вещь поначалу меня опечалила: AST я, конечно, по modelica.g4 получу, но выглядит оно не очень понятно и «юзабельно»: в нём будут тучи лишних узлов, а если не смотреть на токены, а только на узлы, то не всегда понятно, где, например, кончается ветка then, и начинается else.

Опять же, не буду пересказывать документацию на JJTree и неплохой tutorial — в этот раз, правда, не потому, что оригинал блещет подробностью и понятностью, а потому, что я и сам до конца не разобрался, а неправильно, но с уверенностью пересказанная документация, очевидно, хуже отсутствия пересказа. Лучше оставлю небольшие подсказки, выясненные по ходу дела:

  • Во-первых, код описания парсера на JavaCC предполагает наличие вставок на Java, которые будут вписаны в сгенерированный парсер
  • Пусть вас не смущает, что при построении AST синтаксис вроде [ Expression() ] означает необязательность, а в контексте описания токенов — выбор символа, как в регулярном выражении. Насколько я понял объяснение разработчиков PMD, это похожие конструкции, которые имеют такой вот разный смысл — legacy, сэр...
  • Для корневого узла (в моём случае — StoredDefinition) нужно указать вместо void его тип (то есть ASTStoredDefiniton)
  • С помощью синтаксиса #void после имени узла можно скрыть его из распарсенного дерева (то есть он будет влиять только на то, что является корректным исходником, а что нет, и как будут вкладываться остальные узлы)
  • C помощью конструкции вида void SimpleExpression() #SimpleExpression(>1) можно сказать, что узел нужно показывать в результирующем AST, если у него больше одного потомка. Это очень удобно при описании выражений с многими операторами с разными приоритетами: то есть, с точки зрения парсера одинокая константа 1 будет чем-нибудь вроде LogicExpression(AdditiveExpression(MultiplicativeExperssion(Constant(1))))вписать все n уровней приоритетов операций — но код анализатора получит просто Constant(1)
  • У узла имеется стандартная переменная image (см. методы getImage, setImage), в которую обычно проставляется «самая суть» этого узла: например, для узла, соответствующего имени локальной переменной, в image логично скопировать сопоставившийся токен с идентификатором (по умолчанию все токены из дерева будут выкинуты, поэтому стоит скопировать содержащийся в них смысл, во всяком случае, если это что-то переменное, а не просто ключевые слова)
  • LOOKAHEAD — ну, это отдельная песня, ей даже посвящена отдельная глава в документации
    • грубо говоря, в JavaCC если зашёл в узел, откинуть его и попробовать распарсить по-другому уже нельзя, но можно заранее подглядеть вперёд и решить, заходим или нет
    • в простейшем случае, увидев предупреждение JavaCC, вы просто говорите в заголовке LOOKAHEAD = n и получаете загадочные ошибки парсинга, потому что в общем случае оно, вроде, не может решить все проблемы (ну, разве что, выставив несколько миллиардов токенов, вы, фактически, получите предпросмотр всего, но не факт, что оно работает именно так...)
    • перед именем вложенного узла вы можете явно указать, на основании какого числа токенов здесь точно-точно можно принять окончательное решение
    • если в общем случае не существует такого фиксированного количества токенов, вы можете сказать «заходим сюда, если предварительно, начиная с этой точки, удалось сопоставить такой префикс — и далее обычное описание поддерева»
    • будьте осторожны: в общем случае JavaCC не может проверить корректность расстановки директив LOOKAHEAD — он доверяет вам, поэтому хотя бы прикиньте математическое доказательство, почему такого lookahead достаточно...

Теперь, когда у вас есть описание грамматики языка в формате JJTree, эти простые 14 шагов помогут вам добавить поддержку языка. Большинство из них имеют вид «создайте класс похожий на реализацию для java или vm, но адаптированный». Отмечу лишь типичные особенности, часть из них появятся в основной документации, если примут мой pull request для документации:

  • закомментировав удаление всех сгененерированных файлов в скрипте сборки alljavacc.xml (лежащем в вашем новом модуле), вы сможете перенести их в дерево исходников из target/generated-sources. Но лучше не надо. Вероятно, у нас будет изменена лишь небольшая часть, поэтому лучше озаботиться удалением лишь некоторых: увидели необходимость поменять реализацию по умолчанию, скопировали в дерево исходников, добавили в список удаляемых файлов, пересобрали — и теперь вы управляете файлом — конкретно этим файлом. В противном случае будет сложно разобраться, что конкретно изменено, да и поддержку едва ли можно будет назвать приятной
  • теперь, имея реализацию «основного» режима PMD, вы сможете легко навесить на свой JJTree-парсер обвязку ещё и для CPD по аналогии с Java или ещё какой-нибудь имеющейся реализацией
  • не забудьте реализовать метод, возвращающий имя узла для XPath запросов. При реализации по умолчанию там то ли бесконечная рекурсия получается (имя узла через toString и наоборот), то ли ещё что, в общем из-за этого ещё и дерево в PMD Designer не посмотреть, а без этого отлаживать грамматику совсем грустно
  • часть регистраций компонентов делается через добавление в META-INF/services текстовых файлов с fully qualified class name точки входа
  • то, что в правилах можно описать декларативно (например, развёрнутое описание проверки и примеры ошибок), описывается не в коде, а в category/<language name>/<ruleset>.xml — вам в любом случае придётся там регистрировать свои правила
  • … но при реализации тестов, по видимому, активно используется некий, возможно, доморощенный, механизм auto discovery, поэтому
    • если вам говорят «добавьте тривиальный тест на каждую версию языка» — лучше не спорьте, мол «мне это не нужно, это и так работает» — возможно, это механизм auto discovery
    • если вы видите тест на конкретное правило с телом класса, содержащим лишь комментарий // no additional unit tests, то это не тесты отсутствуют, это они просто лежат в ресурсах в виде XML-описания входных данных и ожидаемых реакций анализатора, сразу пачкой: несколько корректных и несколько некорректных примеров.

Маленький, но важный квест: допили PMD Designer

Возможно, у вас получится всё отладить без визуализатора. Но зачем? Во-первых, допилить его очень просто. Во-вторых, это очень сильно поможет вашим пользователям, не знакомым с Java: они легко и просто (если это вообще применимо к XPath), ну или хотя бы без перекомпиляции PMD смогут описывать простые паттерны того, что им не нравится (в простейшем случае — style guide вроде «имя моделиковского пакета всегда начинается со строчной p»).

В отличие от остальных ошибок, которые сразу видны, проблемы с PMD Designer ведут себя достаточно коварно: казалось бы, вы уже поняли, что та надпись Java в правой части меню — это не кнопка, а выпадающий список выбора языка O_o, в котором уже появилась Modelica, потому что в classpath появился новый модуль с регистрациями точек входа. Но вот вы выбираете свой язык, загружаете тестовый файл, и видите AST. И вроде бы, победа, но какое-то оно чёрно-белое, да и выделенное поддерево можно было бы в тексте подсветить — хотя нет, подсветка есть, но обновляется криво — а ещё, как же они не догадались подсвечивать найденные совпадения с XPath… Уже прикидывая объём работ, вы задумываетесь об очередном pull request-е, но тут случайно решаете переключить язык на Java и загрузить какой-нибудь исходник самого PMD… Ой! Оно цветное!.. И подсветка поддерева работает! Эээ… а оно, оказывается, нормально подсвечивает найденные совпадения и выписывает куски текста в окошко справа от запроса… Такое ощущение, что когда в JavaFX-коде во время отрисовки интерфейса происходит exception, он прерывает отрисовку, но не печатается в консоль...

В общем, нужно всего лишь добавить ма-а-аленький класс для подсветки синтаксиса на основе регулярных выражений. В моём случае это был net.sourceforge.pmd.util.fxdesigner.util.codearea.syntaxhighlighting.ModelicaSyntaxHighlighter, который нужно зарегистрировать в классе AvailableSyntaxHighlighters. Обратите внимание, что оба этих изменения происходят в репозитории pmd-designer, артефакт от сборки которого нужно подложить в свой binary distribution.

В итоге это выглядит примерно так (гифка взята из README в репозитории PMD Designer):

PMD Designer в работе

Промежуточный итог

Если вы прошли все указанные уровни, то у вас теперь есть:

  • детектор копипаста
  • движок для выполнения правил
  • визуализатор для отладки AST и приведения его в удобный для анализа вид (как мы уже видели, не все грамматики одного языка одинаково полезны!)
  • тот же визуализатор для отладки XPath-правил, которые ваши пользователи могут писать без перекомпиляции PMD и вообще знание Java (XPath, конечно, тоже не BASIC, но это хотя бы стандарт, а не местный язык запросов)

Надеюсь, у вас также есть понимание того факта, что грамматика теперь является стабильным API вашей реализации поддержки языка — не меняйте её (а точнее описываемую ею функцию преобразования исходника в AST) без крайней необходимости, а уж если поменяли, оповещайте как о breaking change, а то пользователи расстроятся: скорее всего, не все напишут тесты на свои правила, а это очень грустно, когда правила проверяли код, а потом без предупреждения перестали — почти как бекап, совершенно внезапно сломавшийся, причём год назад...

На этом история не заканчивается: предстоит как минимум написать какие-нибудь полезные правила.

Но и это ещё не всё: PMD штатно поддерживает scopes и declarations. С каждым узлом AST ассоциирован scope: тело класса, функции, цикла… Весь файл, на худой конец! А в каждом скоупе есть список определений (declarations), которые он непосредственно содержит. Как и в других случаях, предлагается реализовывать по аналогии с другими языками, например, Моделикой (но на момент написания, логика в моём pull request-е, прямо скажем, сыровата). Код вычисления scopes и declarations представляет из себя visitor, называемый как-нибудь вроде ScopeAndDeclarationFinder, который бегает по дереву и развешивает по нему требуемые ярлычки — в целом, как и обычное правило, только правила, я всё-таки думаю, обычно read-only по отношению к AST. Запускается всё это через ещё один метод класса, описывающего нашу поддержку языка.

public class ModelicaHandler extends AbstractLanguageVersionHandler {

// ...

    @Override
    public VisitorStarter getSymbolFacade() {
        return new VisitorStarter() {
            @Override
            public void start(Node rootNode) {
                new SymbolFacade().initializeWith((ASTStoredDefinition) rootNode);
            }
        };
    }
}

Вывод

Статический анализатор PMD является универсальным и легко расширяемым. Скорее всего, он будет проигрывать специализированным «монстрам» вроде Clang Static Analyzer для конкретных языков, но он очень полезен для реализации статического анализа для нового языка. Но даже если анализаторы уже имеются, поддержку языка можно добавить в CPD (а это ещё проще), и получить поиск скопированных кусков кода в пределах проекта.

Автор: atrosinenko

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js