Пишем плагин для статического анализатора Psalm

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Каким статическим анализатором для PHP пользуетесь вы?
50% Psalm 1
100% PHPStan 2
50% Phan 1
0% Exakat 0
0% Никакие не использую, пишу код без ошибок 0
0% Другое, напишу в комментариях 0
Проголосовали 2 пользователя. Воздержавшихся нет.
Источник: https://habr.com/ru/articles/757188/


Интересные статьи

Интересные статьи

Тут можно найти реализацию готового проектаНа сегодняшний день во многих приложениях мы можем наблюдать stepper, большинство из них кастомные. Несмотря на то, что Apple предоставляет уже реализацию го...
Привет! У нас в Спортмастере очень большая экспертиза в части работы с инструментами JIRA. Про доски, спринты и все прочее все наверняка знают и часто применяют. Мы хотим рассказать о других интересны...
Разберем как использовать облачный сервис Amazon для создания мессенджера Chatty многопользовательского чат-приложения в реальном времени с одной комнатой с помощью ...
Настройка среды. "Голый" бинарник, или Исполняемый файл без main() Первый шаг в написании своей ОСи — создание бинарника, не зависящего от стандартных библиотек, это делает воз...
В последнее время производители FPGA и сторонние компании активно развивают методы разработки для FPGA, отличающиеся от привычных подходов использованием высокоуровневых средств разработки. Я...