Не буду вам рассказывать о всех прелестях использования статических анализаторов, на мой взгляд это очевидно. Об их разновидностях для 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, за то что они позаботились о том, чтобы расширять их функциональность было легко. Еще бы документацию сделали получше, было бы прям идеально.