Разработка 06 ноя 2019 ~9 мин.

Пишем свой Doctrine Annotation Fixer для PHP-CS-Fixer

В плане код-стайла я немного маньяк. Я убежден – чем более строгие правила, тем качественнее будет кодовая база. Когда я только пришел в компанию Axmit в качестве разработчика, моей личной целью стало создание перечня правил, а также настройка всякого рода фиксеров и снифферов. Теперь у нас используется как CodeSniffer, так и PHP-CS-Fixer.

Большинство правил у нас автоматизированы PHPStorm и PHP-CS-Fixer, поэтому следование им не создает слишком много боли.

Но был момент, который не был ни в одном из этих инструментов, заставляюший меня страдать.

Все дело в Doctrine аннотациях. Шторм с ними ничего не делает совсем. А вот фиксер имеет ряд правил, исправляющих отступы, скобочки и т.п.

А вот в чем была проблема. Обратите внимание на запятые.

/**
 * @SWG\Definition(
 *     definition="ActionFilterRequest",
 *     @SWG\Property(property="service_id", type="integer")
 * )
 */
/**
 * @SWG\Definition(
 *     definition="ActionFilterRequest",
 *     @SWG\Property(property="service_id", type="integer",),
 * )
 */

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

Я хочу, чтобы на выходе после исправления это было так:

/**
 * @SWG\Definition(
 *     definition="ActionFilterRequest",
 *     @SWG\Property(property="service_id", type="integer"),
 * )
 */

То есть точно также, как с массивами: при однострочной записи без запятой в конце последнего элемента, а при многострочной – с.

И так, приступим!

Исследование

Внутри библиотеки все фиксеры для доктриновских аннотаций унаследованы от класса \PhpCsFixer\AbstractDoctrineAnnotationFixer с одним единственным абстрактным методом fixAnnotations.

Исследовав другие фиксеры ближе, можно увидеть, что в этот метод передается только один параметр – коллекция токенов. Эта коллекция мутабельная и результат её изменений будет использоваться для формирования итогового кода.

Минимально возможный фиксер, который пока ничего не делает, выглядит примерно так:

//  Тут есть пара неиспользуемых импортов, но скоро они будут нужны
use Doctrine\Common\Annotations\DocLexer;
use PhpCsFixer\AbstractDoctrineAnnotationFixer;
use PhpCsFixer\Doctrine\Annotation\Token;
use PhpCsFixer\Doctrine\Annotation\Tokens;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;

final class DoctrineAnnotationCommasFixer extends AbstractDoctrineAnnotationFixer
{
    /**
     * @return string
     */
    public function getName(): string
    {
        return static::name();
    }

    /**
     * @return string
     */
    public static function name(): string
    {
        return 'Axmit/doctrine_annotation_commas';
    }

    /**
     * @return FixerDefinitionInterface
     */
    public function getDefinition(): FixerDefinitionInterface
    {
        return new FixerDefinition('Fixes commas in Doctrine annotations', []);
    }

    /**
     * Fixes Doctrine annotations from the given PHPDoc style comment.
     *
     * @param Tokens $tokens
     *
     * @return void
     */
    protected function fixAnnotations(Tokens $tokens): void
    {
    }
}

Обратите особое внимание на методы name и getName. Имя фиксера должно быть строго в таком формате, иначе будет ошибка (регулярки можно посмотреть в \PhpCsFixer\FixerNameValidator::isValid). Статическим методом name воспользуемся чуть позже.

Дабы не нагромождать статью простынями кода, все остальные куски исходников будут внутри метода fixAnnotations.

Регистрация

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

  1. Зарегистрировать фиксер в конфиге (файл .php_cs.dist):
    $config->registerCustomFixers([new DoctrineAnnotationCommasFixer()])
    

    Теперь фиксер знает о существовании этого правила, но пока не использует его.

  2. Включить его в рулзах:
    $config->setRules(
        [
            DoctrineAnnotationCommasFixer::name() => true, // помните статический метод name? :-)
        ]
    )
    

И только тогда фиксер не только подключит это правило, но и будет его использовать.

Исправление

Настало время написать логику и оживить наш фикс!

Если описать словами, то суть такова:

  1. Найти все закрывающие обычные и фигурные скобки;
  2. Посмотреть, что перед ними:
    1. Если это пустота (игнорируемый код), то добавить перед ней запятую, если запятой нет;
    2. Если это запятая – удалить

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

$index = 0;
while ($tokens->offsetExists($index)) {
    /** @var Token $token */
    $token = $tokens->offsetGet($index);

    //TODO Add logic here 

    $index++;
}

Класс Token имеет 2 важных свойства – это тип и содержимое. Тип является числовым значением, все из которых находятся в классе DocLexer в виде констант.

Проверить тип токена можно методом isType, куда можно передать либо одно значение, либо массив:

$token->isType([DocLexer::T_CLOSE_CURLY_BRACES, DocLexer::T_CLOSE_PARENTHESIS]);

С первым пунктом разобрались – мы нашли все закрывающие скобки. Далее нужно вытянуть 2 токена перед текущим:

$prevToken     = $tokens->offsetGet($index - 1);
$prevPrevToken = $tokens->offsetGet($index - 2);

Далее просто: если предыдущий токен – ничего (это могут быть пробелы, переносы строк и знак * в любом порядке и количестве; стоит отметить, что в коллекции 2 токена “ничего” подряд не бывает), и токен перед ним не является запятой, то нужно добавить запятую:

if ($prevToken->isType(DocLexer::T_NONE) && !$prevPrevToken->isType(DocLexer::T_COMMA)) {
    $tokens->insertAt($index - 1, new Token(DocLexer::T_COMMA, ','));
}

Иначе, если сразу перед скобкой стоит запятая – убрать:

elseif ($prevToken->isType(DocLexer::T_COMMA)) {
    $prevToken->clear();
}

И вроде бы уже все, но если попробовать запустить фиксер с этим правилом, он никогда не завершит свою работу. Проблема кроется там, где мы добавляем запятую – программа уходит в бесконечный цикл. Чтобы решить эту проблему, достаточно добавить еще один $index++ после добавления запятой:

if ($prevToken->isType(DocLexer::T_NONE) && !$prevPrevToken->isType(DocLexer::T_COMMA)) {
    $tokens->insertAt($index - 1, new Token(DocLexer::T_COMMA, ','));
    $index++; // причина, по которой нужен был цикл while вместо for
}

И все! Фиксер готов, можно выдохнуть :grin:

Собрав все воедино
<?php

use Doctrine\Common\Annotations\DocLexer;
use PhpCsFixer\AbstractDoctrineAnnotationFixer;
use PhpCsFixer\Doctrine\Annotation\Token;
use PhpCsFixer\Doctrine\Annotation\Tokens;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;

final class DoctrineAnnotationCommasFixer extends AbstractDoctrineAnnotationFixer
{
    /**
     * @return string
     */
    public function getName(): string
    {
        return static::name();
    }

    /**
     * @return string
     */
    public static function name(): string
    {
        return 'Axmit/doctrine_annotation_commas';
    }

    /**
     * @return FixerDefinitionInterface
     */
    public function getDefinition(): FixerDefinitionInterface
    {
        return new FixerDefinition('Fixes commas in Doctrine annotations', []);
    }

    /**
     * Fixes Doctrine annotations from the given PHPDoc style comment.
     *
     * @param Tokens $tokens
     *
     * @return void
     */
    protected function fixAnnotations(Tokens $tokens): void
    {
        $index = 0;
        while ($tokens->offsetExists($index)) {
            /** @var Token $token */
            $token = $tokens->offsetGet($index);

            if ($token->isType([DocLexer::T_CLOSE_CURLY_BRACES, DocLexer::T_CLOSE_PARENTHESIS])) {
                $prevToken     = $tokens->offsetGet($index - 1);
                $prevPrevToken = $tokens->offsetGet($index - 2);
                if ($prevToken->isType(DocLexer::T_NONE) && !$prevPrevToken->isType(DocLexer::T_COMMA)) {
                    $tokens->insertAt($index - 1, new Token(DocLexer::T_COMMA, ','));
                    $index++; // prevent infinite loop
                } elseif ($prevToken->isType(DocLexer::T_COMMA)) {
                    $prevToken->clear();
                }
            }

            $index++;
        }
    }
}

Послесловие

Тут стоит отметить пару моментов:

  1. Если вы, как и я, пользуетесь PHPStorm, то вы могли увидеть, что классы AbstractDoctrineAnnotationFixer, Token и Tokens зачеркнуты. А происходит это по простой причине – они помечены как внутренние тегом @internal. Для нас это означает то, что наш фикс может сломаться в любой момент даже между минорными версиями, так как никто не обещает стабильного API во внутреннем коде. (на момент написания статьи использовался friendsofphp/php-cs-fixer:v2.15.1) Так что… все на свой страх и риск.
  2. Тесты… Я уверен, что для этого можно и нужно писать тесты, в том числе и для проверки совместимости с новой версией фиксера, но делать я этого не стал. Просто потому что не нашел это необходимым в данный момент. Возможно, я с этим разберусь и дополню текущую или напишу новую статью.
  3. Потенциально написанное мной правило может работать не так в каких-то крайних случаях. Но я прогнал его по двум нашим проектам, где более 2500 (!) вложенных друг в друга аннотаций, и он расставил запятые во все места и не испортил ни единого файла. Поэтому я считаю этот вариант вполне себе рабочим и проверенным.

Спасибо за прочтение! :blush:


P.S. Внизу, после поста, появился блок с комментариями. Не стесняйтесь задавать вопросы и указывать на ошибки и неточности. Я всегда рад коммуникации :wink:

Подписывайтесь на обновления в Telegram и VK