Не буду вам рассказывать о всех прелестях использования статических анализаторов, на мой взгляд это очевидно. Об их разновидностях для PHP интересно рассказано, например здесь. А я расскажу о том как можно расширить их функциональность под ваши нужды, на примере Psalm.
В своих проектах я использую несколько анализаторов, но к сожалению столкнулся с одной проблемой, с которой ни один не смог помочь. И здесь я решил попробовать расширить его функциональность. Собственно об опыте написания подобного расширения возможностей и будет статья.
Немного о проблеме
Если вы когда нибудь пользовались JMS Serializer то знаете на сколько это удобная штука. Вы просто описываете класс определенным образом, и через вызов метода можете получить объект из JSON/XML данных, где все свойства будут смаплены согласно вашему описанию, и наоборот, из объекта получить JSON/XML.
Вот так может выглядеть описание объекта для сериализации или дессериализации.
<?php class ExampleDTO { /** * @JMS\Type("int") */ public $id; /** * @JMS\Type("\ExampleTypeDTO") */ public $type; }
Основная проблема здесь в том, что класс для $type описан как строка, и ни один линтер не видит его, и вообще не может понять что здесь описан именно класс.
И когда вы проводите рефакторинг, то случайно можете переименовать или переместить его. И если у вас не написаны тесты, и вы плохо провели тестирование, то получите ошибку при попытке сериализации, в тестинге или хуже, в продакшене.
Как начать?
Начал я с официальной документации из которой узнал что уже есть скелет плагина на гитхабе.
Как здорово что разработчики псалма позаботились обо мне, подумал я, и решил что все будет легко.
Создав проект из скелета, я в первую очередь озаботился возможностью написания тестов. Так как документация не раскрывает всех возможностей, то придется много копаться в коде, чтобы понять что и как должно работать. Поэтому очень хотелось описать какой нибудь файл как фикстуру и через тесты уже запускать и экспериментировать с возможностями.
К сожалению я не увидел легкой возможности написать юнит тест на работу плагина. Заглянув в код почти всех существующих расширений, я попробовал разобраться как они тестировали свою функциональность. Везде все реализовывали тесты по разному. Но спустя несколько часов разбора я собрал более менее подходящую мне конфигурацию, и написал свой первый тест.
Как выяснилось позже, я сам допустил ошибку, так как долгое время не писал ничего кроме юнит и Testsuite тестов, и не заметил в скелете приложения ключевого слова acceptance! Мог бы сэкономить пару часов )
Оказывается разработчики позаботились о том, чтобы я легко мог протестировать то что сделал, я просто не знал куда смотреть.
В итоге у меня получился примерно такой тест
Feature: basics Test my plugin Background: Given I have the following config """ <?xml version="1.0"?> <psalm totallyTyped="true"> <projectFiles> <directory name="."/> </projectFiles> <plugins> <pluginClass class="Tooeo\PsalmPluginJms\Plugin" /> </plugins> </psalm> """ Scenario: run without errors Given I have the following code """ <?php namespace Tooeo\PsalmPluginJms\Tests\Fixtures; class SomeTestFile { /** * @JMS\Type(array<\Api\User\Dto\CurrencyError>); * @psalm-suppress PropertyNotSetInConstructor */ public string $errorArray; } """ When I run Psalm Then I see these errors | Type | Message | | UndefinedDocblockClass | Class \Api\User\Dto\CurrencyError does not exists | And I see no other errors
Замечательно, просто описываем в файле то что нужно, и все работает.
# запускаем тесты codecept build && codecept --ansi run acceptance ..... ✔ basics: run without errors (1.84s) ✔ basics: run with errors (0.98s) ✔ basics: run with suppressed errors (0.98s) ------------------------------------------------------------ Time: 00:03.825, Memory: 10.00 MB OK (3 tests, 0 assertions)
В общем я дописал и приемочные тесты к моим уже готовым юнит тестам.
Пишем проверки
Из документации я узнал что нужно создать и зарегистрировать хендлер(или хук). В исходниках псалма и других плагинах нашел примеры, и выглядеть это должно примерно так:
<?php class Plugin implements PluginEntryPointInterface { public function __invoke(RegistrationInterface $psalm, ?SimpleXMLElement $config = null): void { require_once __DIR__ . '/src/Hooks/JmsAnnotationCheckerHook.php'; $psalm->registerHooksFromClass(JmsAnnotationCheckerHook::class); } }
Данный хендлер должен имплементировать один из интерфейсов, что определяет в какой момент и с каким набором данных он будет вызываться.
Я довольно много провозился подбирая нужный мне, так как в документации не особо раскрыто назначение каждого из интерфейсов.
Для себя я выбрал AfterClassLikeAnalysisInterface что позволяет запускать мой хук сразу после завершения анализа класса.
Достаем данные для анализа
Если вы имплементируете класс AfterClassLikeAnalysisInterface, то обязаны реализовать метод afterStatementAnalysis принимающий объект AfterClassLikeAnalysisEvent
<?php class JmsAnnotationCheckerHook implements AfterClassLikeAnalysisInterface { public static function afterStatementAnalysis(AfterClassLikeAnalysisEvent $event) { /** * Здесь можно найти информацию о классе * Например: * - информация о неймспейсе * - список свойств * - список методов * - список трейтов * - список атрибутов */ $stmt = $event->getStmt(); /** * Здесь можно найти информацию связанную с классом полученную в результате анализа * Например: * - все интерфейсы которые имплементирует класс * - родительские классы * - местонахождение класса в файловой системе * - все подключенные неймспейсы * ..... * Вообще там много чего, стоит в него заглянуть */ $storage = $event->getClasslikeStorage(); } }
Этого мне было достаточно для моей задачи. Циклом я прошелся по всем свойствам и проверил комментарии и атрибуты к ним на наличие аннотации в стиле JMS Serializer, и если находил то пробовал понять, а класс ли описан в типе или нет.
Для того чтобы это понять, я исключил все стандартные типы, а все остальное считал классами.
Небольшой пример того какими могут быть описания свойств
/* * @JMS\Type(DateTime<\'Y-m-d\'>) * @JMS\Type("DateTime<\'d.m.Y H:i:s\', \'UTC\'>") * @JMS\Type('string') * @JMS\Type(name="string") * @JMS\Type(name=array<int, enum<JmsDto::class>>) * @JMS\Type('\Fixture\JmsDto') * @JMS\Type(array<array<string, DateTime<\'d.m.Y H:i:s\', \'UTC\'>>>) */
Как видно, объекты могут быть любой вложенности, и из приведенного примера мне нужно проверить только JmsDto::class.
Для определения, существует ли он, я искал его во всех включенных неймспейсах. То есть, брал все что указано в use и текущий неймспейс, прикреплял к нему название класса и проверял на class_exists.
Лучше приведу пример реализации:
<?php namespace Tooeo\PsalmPluginJms\Tests\Fixtures; use Tooeo\PsalmPluginJms\Tests\Fixtures\JmsDto; use Tooeo\PsalmPluginJms\Tests\Fixtures\JmsDto as JmsDtoAlias; /** * @var string $class - название класса который спарсили из комментария. * Может принимать значения: * - Tooeo\PsalmPluginJms\Tests\Fixtures\JmsDto * - JmsDtoAlias * - FixturesAlias\JmsDto::class * - SameNamespace::class * * @var array $uses - список подключаемых неймспейсов * Может принимать значения: * $uses = [ * 'jmsdto' => 'Tooeo\PsalmPluginJms\Tests\Fixtures\JmsDto', * 'jmsdtoalias' => 'Tooeo\PsalmPluginJms\Tests\Fixtures\JmsDto', * ]; * @var string $namespace - текущий неймспейс * Может принимать значения: * - Tooeo\PsalmPluginJms\Tests\Fixtures */ public static function isClassExists(string $class, array $uses, string $namespace): bool { $class = explode('::', $class)[0]; foreach ($uses as $flipped => $use) { if ($flipped === strtolower($class)) { $class = $use; break; } } foreach ($uses as $flipped => $use) { if (preg_match("#^$flipped#i", $class)) { $class = preg_replace("#$flipped#i", $use, $class); break; } } return preg_match('#interface$#i', $class) ? interface_exists($class) || interface_exists($namespace.'\\'.$class) : class_exists($class) || class_exists($namespace.'\\'.$class); }
Реализация логики моего плагина не совсем относится к теме статьи, поэтому не буду уделять этому много времени, весь код можно посмотреть на гитхабе.
Конфигурация плагина
Когда я запустил проверку на своем проекте, выяснилось, что в JMS Serializer можно указывать кастомные типы. А их к сожалению не опишешь. Поэтому я начал разбираться как же можно конфигурировать мой плагин.
Это оказалось не сложным, разработчики уже обо всем подумали. В __invoke метод моего плагина передавался объект SimpleXMLElement $config, и в нем есть вся конфигурация которую можно описать при его подключении.
Выглядит это у так:
<pluginClass xmlns="https://getpsalm.org/schema/config" class="Tooeo\PsalmPluginJms\Plugin"> <ignoringTypes> <ignored>FeatureBasedDateTime</ignored> <ignored>MixedType</ignored> <ignored>ArrayOrString</ignored> <ignored>float_amount</ignored> <ignored>ContractType</ignored> <ignored>DateTimeRFC3339</ignored> </ignoringTypes> </pluginClass>
А разбор примерно так:
<?php public function __invoke(RegistrationInterface $psalm, ?SimpleXMLElement $config = null): void { foreach ($config?->ignoringTypes ?? [] as $toIgnore) { // Собственно здесь находится вся конфигурация } }
Информация об ошибке
Теперь мы умеем анализировать класс и находить несуществующие классы в описании. И нам нужно как то сообщить Psalm о том что есть ошибка.
Делается это через метод IssueBuffer::maybeAdd().
Для своей задачи я нашел класс описывающий ошибку UndefinedDocblockClass но вы можете создать свой кастомный класс конкретно под вашу.
Особых проблем здесь я не испытал, стоит только сказать что 2-м параметром в метод maybeAdd нужно передать все найденные в классе Suppressed, иначе ошибка будет проигнорирована.
Можно сделать это примерно так:
<?php // Это псевдокод для того чтобы показать как может быть реализовано. class CheckerHook implements AfterClassLikeAnalysisInterface { public static function afterStatementAnalysis(AfterClassLikeAnalysisEvent $event) { foreach ($event->getClasslikeStorage()->properties as $name => $propertyStorage) { // Получаем свойство для анализа if (!$property = $event->getStmt()->getProperty($name)) { continue; } // Соберем все что хотим в данный момент заигнорить, это информация из класса(общая) и конкретно проверяемого свойства $suppressed = array_merge( $event->getClasslikeStorage()->suppressed_issues, $propertyStorage->suppressed_issues ); // Вызываем метод возможного добавления ошибки. // Он проверит $suppressed и если не найдет противоречий то добавит ошибку IssueBuffer::maybeAdd( new UndefinedDocblockClass( 'My first validation Issue', $propertyStorage->stmt_location, $name // если честно, не разобрался что это за параметр, и на что он влияет. Может в комментах подскажете? ), $suppressed, true ); } } }
В итоге, запустив проверку с включенным плагином я нашел реальные проблемы в коде. Да это был легаси код который не использовался в продакшене, но в любой момент кто то мог начать его использовать и получил бы проблемы.
Итог
Я хотел показать, что расширить функциональность линтера под свои нужды не так сложно как может показаться. Если вы сталкиваетесь с какими-то проблемами в разработке, и эту проблему можно закрыть используя статический анализатор, то не нужно бояться, нужно просто написать плагин, это ведь так просто И возможно, когда плагинов станет много, баги во всех продакшенах мира исчезнут :)
Еще хотел бы выразить респект разработчикам Psalm, за то что они позаботились о том, чтобы расширять их функциональность было легко. Еще бы документацию сделали получше, было бы прям идеально.