Пишем свой 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: