JavaParser. Корёжим код легко и непринуждённо  



JavaParser. Корёжим код легко и непринуждённо +32

В мире существует множество клёвых маленьких библиотек, которые как бы и не знаменитые, но очень полезные. Идея в том, чтобы потихоньку знакомить Хабр с такими вещами. Сегодня расскажу о JavaParser.


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


Где-то посреди статьи вы ВНЕЗАПНО можете осознать, какой кошмар и ужас можно сотворить этой либой, и никак не дождётесь дочитать текст и полить меня гневными комментариями. Не сдерживайтесь, не стоит — сразу скрольте до самого низу и изливайте душу :)




Код распространяется на гитхабе под лицензиями Apache, LGPL и GPL. Авторы сделали для проекта относительно приличный сайт, и даже запилили небольшую книжку, распространяемую совершенно бесплатно — что как бы подтверждает серьёзность их намерений.


Я перекинулся парой вопросов с авторами либы на FOSDEM, авторы оставляют впечатление умных и адекватных людей. Эта статья основана на их докладе.


Что делает эта либа? Вначале она превращает Java-код в AST (абстрактное синтаксическое дерево) — parsing. Во-вторых, она может взять уже готовый AST и превратить его в Java-код — unparsing.


В чем тут засада, и зачем нам вообще нужны библиотеки. Глядите:


String habraPostText = "Пыщ пыщ пыщ, ололо, я водитель НЛО";

public void writeHabraPost(String habraPostText) {
    habraPostText = "Привет, Хабр. Я сегодня пьян.";
}

public void writeHabraPost() {
    habraPostText = "Привет, Хабр. Я сегодня пьян.";
}

Для обоих этих методов AST в JavaParser сгенерится одинаковый. Конкретная нода в AST не знает, откуда именно пришел habraPostText.


Или например, у нас будут методы aMethod(int foo) и aMethod(String foo), внутри которых переменная печатается с помощью System.out.println. AST тоже выйдет одинаковый.


Поэтому в JavaParser есть так называемый symbol solver, который каждому куску AST вычисляет конкретные соответствующие куски исходника. Он у них лежит в виде отдельного проекта на GitHub под названием JSS.


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


Теперь, зачем всё это может быть нужно. Например, вы хотите автоматизировать генерацию мусорного кода (привет, Lombok!). Или написать транспилер, который код на каком-то выдуманном вами скриптовом языке будет превращать в Java.


Код, который такое делает, очень простой. Давайте сделаем класс хабрапоста:


CompilationUnit cu = new CompilationUnit();
cu.setPackageDeclaration("ru.habrahabr.hub.java.examples.javaparser");

ClassOrInterfaceDeclaration habraPost = cu.addClass("HabraPost");
habraPost.addField("String", "title");
habraPost.addField("String", "text");

И теперь добавим конструктор, заставляющий заполнить эти поля:


habraPost.addConstructor(Modifier.PUBLIC)
         .addParameter("String", "title")
         .addParameter("String", "text")
         .setBody(new BlockStmt()
            .addStatement(new ExpressionStmt(new AssignExpr(
                new FieldAccessExpr(new ThisExpr(), "title"),
                new NameExpr("title"),
                AssignExpr.Operator.ASSIGN)))
            .addStatement(new ExpressionStmt(new AssignExpr(
                new FieldAccessExpr(new ThisExpr(), "text"),
                new NameExpr("text"),
                AssignExpr.Operator.ASSIGN))));

Теперь сгенерим бойлерплейт для геттеров-сеттеров:


habraPost.addMethod("getTitle", Modifier.PUBLIC).setBody(
        new BlockStmt().addStatement(
                new ReturnStmt(new NameExpr("title"))));

habraPost.addMethod("getText", Modifier.PUBLIC).setBody(
        new BlockStmt().addStatement(
                new ReturnStmt(new NameExpr("text"))));

И теперь распечатаем наш класс в консоль:


System.out.println(cu.toString());

На выходе получится что-то вроде:


package ru.habrahabr.hub.java.examples.javaparser;

public class HabraPost {
    String title;
    String text;

    public HabraPost(String title, String text) {
        this.title = title;
        this.text = text;
    }

    public void getTitle() {
        return title;
    }

    public void getText() {
        return text;
    }
}

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


long wtfs = getNodes(myAPI, MethodDeclaration.class).stream()
         .filter(m -> m.getParameters().size > 10)
         .count();

System.out.println(String.format("Количество методов, за которые тебя стоит уволить: %d", wtfs));

Я не буду ударяться в сложные примеры, так как там всё видно по API. Единственная реально необычная фича, это то, что можно позвать JSS и покопаться в типах. Например, давайте найдём такой класс, который унаследован от максимального количества других классов (рекурсивно):


ResolvedReferenceTypeDeclaration c = 
    getNodes(myAPI, ClassorInterfaceDeclaration).stream()
    .filter(c -> !c.isInterface())
    .map(c -> c.resolve()) // JSS!
    .sorted(Comparator.comparingInt(o -> -1 * o.getAllAncestors().size)))
    .findFirst().get();

В результате, эту либу можно использовать для автоматического рефакторинга. Для этого можно не иметь продвинутых рефакторилок внутри IDE, а ограничиться исключительно возможностями JavaParser. Давайте сделаем рефакторинг: замену вызова более старого метода на более новый.


Помните вот эту историю?




Допустим у нас есть метод checkMegamozg(Boolean moderatorInAGoodMood), который Boomburum запускает у себя в мозгу 666 раз в день. Нужно превратить его в checkHabrahabr(Boolean moderatorInAGoodMood).


Вначале мы ищем нужный метод:


getNodes(myAPI, MethodCallExpr.class).stream()
    .filter(m -> m.resolveInvokedMethod()).
                 getQialifiedSignature()
                 .equals("ru.habrahabr.Habr.checkMegamozg(java.lang.Boolean)"))
    .forEach(m -> m.replace(replaceCallsToMegamozg(m)));

Теперь как именно будет выглядеть замена:


public MethodCallExpr replaceCallsToMegamozg() {
    MethodCallExpr newMethodCall = new MethodCallExpr(
            methodCall.getScope.get(), "checkHabrahabr");
    newMethodCall.addArgument(methodCall.getArgument(0));
    return newMethodCall;
}

Причём если где-то там внутри методов затесались комментарии (привет, lany!), JavaParser всячески пытается их не потерять. Это жутко неприятная задача и авторы очень парятся об этой теме.


Как видим, эта либа — как маленький швейцарский ножик, простая и относительно надёжная. В будущем будут добавляться небольшие фичи типа встроенного языка шаблонов, чтобы можно было генерить Java-классы не вручную, а подгрузив их из файла и заменив ${плейсхолдеры}.


Вродё всё. Ставьте лайки, подписывайтесь, и обязательно расскажите о том, что думаете об этой либе! Кроме того, можно заказать мне мини-обзор ещё какой-нибудь библиотеки — самые лучшие предложения превратятся в хабропосты.


Минутка рекламы. Как вы, наверное, знаете, мы делаем конференции. Ближайшие — JBreak 2018 и JPoint 2018. Можно туда прийти и вживую пообщаться с разработчиками разных моднейших технологий, например там будет Simon Ritter — Deputy CTO из Azul Systems. Там же можно встретиться c Виктором Гамовым из "Разбора Полётов" и кучей других интересных людей (и с бездельниками типа меня тоже можно пересечься). Короче, заходите, мы вас ждём.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (26):

  1. kafeman
    /#10660726 / +1

    Самый важный вопрос. Умеет ли это сохранять «незначимые символы», а проще говоря пробелы?

    • olegchir
      /#10660764

      Хороший вопрос. Про это они рассказывали отдельно. Это называется Lexical Preservation.

      Есть отдельный тикет про пробелы, который якобы решен: github.com/javaparser/javaparser/issues/124

      И есть некая начальная реализация этой идеи: github.com/javaparser/javaparser/pull/654

      В частности, текущая реализация совершенно точно старается сохранить наличие комментариев.

      По поводу всего остального, есть проблемы.

      В частности вот тут оказывается, что их реализация добавляет и удаляет переносы строк там, где не нужно: github.com/javaparser/javaparser/issues/1293

      А вот тут люди заметили, что ноды реордерятся без особой нужды: github.com/javaparser/javaparser/issues/912

      С другой стороны, там уже 4 тысячи коммитов, и они продолжают идти (последний коммит был несколько дней назад), так что — может и дождемся?

      Кстати, а зачем тебе сохранять пробелы?

      • Dr_XaoS
        /#10661070 / +2

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

        • olegchir
          /#10661176 / +1

          Если будешь разбираться, как оно там внутри пробелы расставляет (и можно ли таким образом повлиять на форматирование) — напиши, пожалуйста?

          • kafeman
            /#10661382

            Я так понимаю, Dr_XaoS под рефакторингом имел ввиду не расстановку пробелов по собственным правилам, а примерно то же, что я описал ниже. Например, есть задача сменить название класса Foo на Bar. Тогда ожидается, что будут заменены только эти 3 символа, а не «покорежен» (как вы нам обещаете в заголовке) весь файл.

      • kafeman
        /#10661378

        Кстати, а зачем тебе сохранять пробелы?
        Чтобы получать минимальные диффы при коммите. В частности, страшно раздражают графические редакторы, которые из-за одной незначительной правки корежат весь файл (например, SVG). После нескольких десятков изменений уже практически невозможно отследить кто, когда и что менял.

        • olegchir
          /#10661414

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

          • kafeman
            /#10661434

            В любом случае неприятно, когда все форматирование идет «коту под хвост». Если я поменял в редакторе цвет квадратика с синего на красный, то только blue на red в строго определенном месте он должен заменить. А не выворачивать все шиворот-навыворот.

            • olegchir
              /#10661650

              Ну просто lexical preservation это капец какая сложная задача :)

              • kafeman
                /#10661954

                Не самая простая, возможно. Но и далеко не самая сложная относительно других задач приложения. Просто разработчикам на результат совершенно «по барабану».

                • olegchir
                  /#10661996

                  Всё-таки настаиваю, что задача капец какая сложная. Потому что все трансформации выполняются на уровне AST, а не исходного кода. А AST ничего о сырце до лексера не знает. То есть это не так что ты прошёлся по текстовому файлу, и заменил там все названия класса с одного на другой find-and-replace'ом, а какая-то безумная конструкция, которая кроме логики должна хранить ещё и представление где-то между строчек…

                  • kafeman
                    /#10662120

                    Интересно, если это «капец какая сложная», то какой эпитет вы подберете для по-настоящему сложных задач? :-)

                    А AST ничего о сырце до лексера не знает.
                    Правильнее сказать, интерфейс AST-узлов не знает ничего об исходной структуре. Но это не мешает нам придумать для него любую реализацию — может, у вас синтаксическое дерево вообще в виде записей в SQL-таблице хранится? Тогда очевидно, что реализация узла должна знать, как минимум, первичный ключ записи в таблице.

                    • olegchir
                      /#10662904

                      Эпитет «всё пропало» :-) Задача достаточно сложная, чтобы в IntelliJ IDEA и Eclipse этой возможности реализовано не было, хотя делают эти редакторы лучшие в мире программисты и компании, специально занимающиеся данным вопросом

                      хотя, если у тебя есть идеи как это сделать правильно, — можешь поделиться ими прямо здесь?

                      • kafeman
                        /#10662978

                        Это решается по-разному в зависимости от других задач приложения. В случае с простым текстовым редактором у вас уже есть некий буфер с оригинальным текстом, который вы выводите на экран. Соответственно, в AST-узлах могут быть проставлены ссылки (в Java — целочисленное смещение относительно начала буфера) на соответствующие им куски кода. Если редактор офигеть какой навороченный (не уверен, относятся ли сюда Eclipse и IDEA, это больше ко всяким Word'ам), то там набираемый текст может храниться в несколько более странном виде, например, rope (как это перевести на русский? :-) Тогда лучше хранить куски текста «вокруг» AST-узлов (которые очень хорошо сжимаются). Почему это не сделано в упомянутых вами программах — не знаю, лентяи, наверное. Я лично реализовывал подобное для одного небольшого конфигурационного языка с Си-подобным синтаксисом (пользователь должен иметь возможность изменить настройки как руками, так и через GUI, и чтобы при этом файл все еще был пригоден для ручного редактирования). Задача, действительно, не очень распространенная (в силу лени других разработчиков), но и не «капец какая сложная», если разработчик имеет привычку думать самостоятельно, а не сдаваться, не найдя готового решения в Интернете. Возможно, мое решение не самое лучшее (потому при случае интересуюсь, как это делают другие), но более детальное описание чем то, что размещено выше, дать не могу, чтобы не деанонимизироваться.

                        Off topic
                        Прощу прощения, все никак не удается вспомнить — мы разве с вами знакомы? Почему вы все время обращаетесь ко мне на «ты»?

                        • kafeman
                          /#10663140

                          Сейчас у меня нет времени, но давайте на следующих выходных я напишу статью с примером парсера простых математических выражений типа 1 + (2* 1). Пробелы при этом, естественно, будут сохраняться. От парсера какой-нибудь Java этот пример практически ничем не отличается. Чтобы вы не пропустили публикацию, отправлю вам ссылку в личку.

                        • olegchir
                          /#10663618

                          Не хотелось бы, чтобы в аст был какой-то мусор (типа ссылок на соответствующие куски кода). Это смешивание смысла и представление в одной сущности, это плохо. Можешь сделать так, чтобы аст остался идеально чистым?

                      • Maccimo
                        /#10663414

                        Задача достаточно сложная, чтобы в IntelliJ IDEA и Eclipse этой возможности реализовано не было, хотя делают эти редакторы лучшие в мире программисты и компании, специально занимающиеся данным вопросом

                        Апелляция к авторитету? Так себе аргумент.
                        Вполне возможно, что просто сочли нецелесообразным тратить на это время.
                        Разработчики в штате — потому, что есть дела поважнее, опенсорс контрибьюторы — потому, что есть задачи поинтереснее.
                        Что вас пугает в этой задаче?


                        В идее и кнопки на тулбарах через GUI не поменять (через API можно). Тоже скажете, что это ужасно сложная задача?

                        • olegchir
                          /#10663610

                          Мне кажется сложным следующее. Красивый дизайн приложения сейчас строится на разделении ортогональных вещей. Представление должно быть отделено от смысла.

                          Например, в веб-приложении у тебя есть строго отдельный HTML, строго отдельный CSS в виде классов, отдельные данные на клиенте, отдельные данные на сервере в контроллерах, отдельные — в сущностях БД мапящихся с помощью ORM, отдельные — собственно в базе данных. Цепочки всевозможных dto-маперов и редюсеров для перебрасывания инфы, архитектуры типа MVC и react-redux, итп. Если же ты попытаешься не делать так и смешаешь логику и представление (условное JSP с вызовом из него БД селектами), это будет некрасиво, и тебе за это отрубят голову, насадят на кол, и поставят на центральной площади, чтобы другим непоавдно было

                          так вот, можно взять один аст, и засунуть прямо в него какие-то маркеры, пустые ноды, итп. Но это будет равносильно как если бы ты перемешал HTML+CSS в одном файле — это прямой путь чтобы твоя голова на колу висела на центральной площади

                          а как сделать это красиво и правильно, с разделением, пока непонятно

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

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

                          • kafeman
                            /#10663642

                            так вот, можно взять один аст, и засунуть прямо в него какие-то маркеры, пустые ноды, итп. Но это будет равносильно как если бы ты перемешал HTML+CSS в одном файле
                            Еще раз, зачем вы мешаете в кучу интерфейс и реализацию? Есть, например, интерфейс AST-узла. Через него нельзя получить никакие маркеры. А есть реализация. В ней может находиться все, что душе разработчика угодно. Могу вам назло FTP-сервер туда засунуть.

                            На вашем примере HTML+CSS разделение происходит только на уровне некого интерфейса между веб-разработчиком и веб-браузером. Загляните, например, в исходники WebKit — там у DOM-узла (читайте AST-узла) в реализации не то что стили, там даже рендерер свой имеется.

                            • olegchir
                              /#10663878

                              Наверное, ты прав. Буду ждать твою статью.

                • olegchir
                  /#10662000

                  Тут бы спросить lany о том, как там в Идее с сохранением форматирования при рефакторингах. Всё хорошо?

                  • lany
                    /#10662014

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

  2. DSolodukhin
    /#10660990 / +2

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

    • olegchir
      /#10661654

      Для чего конкретно пригодился бы, можешь рассказать?

      • DSolodukhin
        /#10664114

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

  3. igor_suhorukov
    /#10661272 / +1

    По мне так ecj compiller ближе к реальным проектам. С Его AST легко работать https://m.habrahabr.ru/post/336732/