В плане код-стайла я немного маньяк. Я убежден – чем более строгие правила, тем качественнее будет кодовая база. Когда я только пришел в компанию 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 вещи:
- Зарегистрировать фиксер в конфиге (файл
.php_cs.dist
):$config->registerCustomFixers([new DoctrineAnnotationCommasFixer()])
Теперь фиксер знает о существовании этого правила, но пока не использует его.
- Включить его в рулзах:
$config->setRules( [ DoctrineAnnotationCommasFixer::name() => true, // помните статический метод name? :-) ] )
И только тогда фиксер не только подключит это правило, но и будет его использовать.
Исправление #
Настало время написать логику и оживить наш фикс!
Если описать словами, то суть такова:
- Найти все закрывающие обычные и фигурные скобки;
- Посмотреть, что перед ними:
- Если это пустота (игнорируемый код), то добавить перед ней запятую, если запятой нет;
- Если это запятая – удалить
По коллекции токенов можно пройтись циклом, как по массиву. Но мы этого делать не будем по причине, которую я объясню позже.
$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++;
}
}
}
Послесловие #
Тут стоит отметить пару моментов:
- Если вы, как и я, пользуетесь PHPStorm, то вы могли увидеть, что классы
AbstractDoctrineAnnotationFixer
,Token
иTokens
зачеркнуты. А происходит это по простой причине – они помечены как внутренние тегом@internal
. Для нас это означает то, что наш фикс может сломаться в любой момент даже между минорными версиями, так как никто не обещает стабильного API во внутреннем коде. (на момент написания статьи использовалсяfriendsofphp/php-cs-fixer:v2.15.1
) Так что… все на свой страх и риск. - Тесты… Я уверен, что для этого можно и нужно писать тесты, в том числе и для проверки совместимости с новой версией фиксера, но делать я этого не стал. Просто потому что не нашел это необходимым в данный момент. Возможно, я с этим разберусь и дополню текущую или напишу новую статью.
- Потенциально написанное мной правило может работать не так в каких-то крайних случаях.
Но я прогнал его по двум нашим проектам, где более
2500 (!) вложенных друг в друга аннотаций, и он расставил запятые во все места и не испортил ни единого файла. Поэтому я считаю этот вариант вполне себе рабочим и проверенным.
Спасибо за прочтение! :blush:
P.S. Внизу, после поста, появился блок с комментариями.
Не стесняйтесь задавать вопросы и указывать на ошибки и неточности.
Я всегда рад коммуникации :wink: