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

Комментарии (0)