Pular para o conteúdo

Como criar uma regra personalizada de downgrade do Rector

O pipeline de backport do NextPDF faz o downgrade do código-fonte em PHP 8.4 do nextpdf/nextpdf para que ele rode em PHP 8.1 e, no caso do core, em PHP 7.4. Ele usa os conjuntos de downgrade integrados do Rector para a maioria dos recursos da linguagem, além de um pequeno conjunto de regras personalizadas para os recursos que o Rector não cobre.

Este guia mostra como adicionar uma nova regra personalizada. Ela segue a mesma estrutura das três regras que já estão no repositório: DowngradeAsymmetricVisibilityRector, DowngradeCloneWithRector e DowngradeTraitConstantsRector. As três ficam em rector/rules/, são registradas em rector/config/ e têm testes baseados em fixtures em tests/Rector/.

Use este guia quando um recurso do NextPDF usar uma sintaxe de PHP que o alvo de build não suporta e o Rector não tiver um downgrade integrado para ela. Antes de começar, confirme que o Rector realmente não oferece uma regra. A sequência integrada withDowngradeSets() já trata classes readonly, constantes de classe tipadas, o operador pipe e muitos outros recursos.

Você escreve e executa regras personalizadas dentro do repositório nextpdf-backport. O composer.json dele declara as ferramentas de desenvolvimento.

  1. Clone o repositório de backport e instale as dependências.
  2. Confirme que o Rector e o PHPUnit estão disponíveis.
Terminal window
git clone https://github.com/nextpdf-labs/backport.git
cd backport
composer install
Terminal window
vendor/bin/rector --version
vendor/bin/phpunit --version

O repositório exige PHP 8.4 para rodar porque as regras manipulam árvores de sintaxe da 8.4, mesmo que a saída do build tenha como alvo PHP 8.1 ou 7.4. O composer.jsonrequire fixa o php em >=8.4 <9.0. As regras personalizadas são carregadas automaticamente no namespace NextPDF\Backport\, que mapeia para rector/rules/; os testes são carregados automaticamente em NextPDF\Backport\Tests\, que mapeia para tests/.

Toda regra personalizada estende Rector\Rector\AbstractRector e implementa três métodos. O contrato é o mesmo para as três regras existentes.

MembroTipoFinalidade
getRuleDefinition()RuleDefinitionUma descrição legível por humanos e pares before/after de CodeSample para o gerador de documentação de regras.
getNodeTypes()array<class-string<Node>>As classes de nó da Abstract Syntax Tree (AST) que a regra visita. O Rector chama refactor() apenas para essas classes.
refactor(Node $node)?Node ou `Stmt[]null`

A declaração dos tipos de nó orienta a regra. DowngradeAsymmetricVisibilityRector tem como alvo [Property::class, Param::class] porque a visibilidade assimétrica (public private(set)) pode aparecer tanto em uma propriedade de classe quanto em um parâmetro de construtor promovido. DowngradeTraitConstantsRector tem como alvo [Trait_::class] porque reescreve o corpo inteiro do trait em uma única passagem. DowngradeCloneWithRector tem como alvo [FileNode::class, Return_::class, Expression::class] porque o clone-with (clone($obj, [...])) pode aparecer tanto em posições de return quanto de atribuição; ele usa a visita ao FileNode para reiniciar um contador por arquivo.

Uma regra que retorna null a partir de refactor() sinaliza “nenhuma alteração”. Uma regra que retorna um nó sinaliza uma substituição. Uma regra que retorna Stmt[], uma lista de instruções, expande uma instrução em várias. É assim que DowngradeCloneWithRector transforma um único return clone($this, [...]); em uma atribuição de clone, uma atribuição sobrescrita de propriedade e um return final.

Os dois arquivos de configuração do pipeline chamam ->withDowngradeSets(php81: true) e ->withDowngradeSets(php74: true). Esses conjuntos encadeiam todas as regras de downgrade integradas para o alvo. As regras personalizadas existem apenas para cobrir lacunas: visibilidade assimétrica do PHP 8.4, clone-with do PHP 8.5 e constantes de trait do PHP 8.2. O Rector não faz o downgrade de nenhuma delas por conta própria. Só escreva uma regra personalizada depois de confirmar que a lacuna é a mesma.

Este procedimento adiciona uma nova regra. O exemplo em uso espelha as regras existentes; substitua pelo seu próprio recurso.

  1. Crie a classe da regra em rector/rules/. Nomeie-a como Downgrade<Feature>Rector e coloque-a no namespace NextPDF\Backport para que o mapeamento de autoload existente a reconheça.
  2. Estenda Rector\Rector\AbstractRector e marque a classe como final.
  3. Implemente getNodeTypes() para retornar o conjunto mais restrito de classes de nó AST de que a regra precisa. Um conjunto mais restrito faz o Rector visitar menos nós.
  4. Implemente refactor(). Verifique se o nó é um dos tipos declarados, proteja contra tudo que não seja a sintaxe exata que você está mirando, transforme-o e retorne o novo nó, Stmt[] ou null para nenhuma alteração.
  5. Implemente getRuleDefinition() com um before/after CodeSample para cada caso distinto que a regra trata.
  6. Mantenha o arquivo no PHPStan Level 10: tipifique todos os parâmetros, retornos e propriedades e use generics do PHPDoc para descrever os formatos dos arrays.

A regra de visibilidade assimétrica é o menor exemplo completo. Ela remove as flags de visibilidade de escrita (set) e garante que uma visibilidade de leitura básica permaneça definida:

rector/rules/DowngradeAsymmetricVisibilityRector.php
<?php
declare(strict_types=1);
namespace NextPDF\Backport;
use PhpParser\Modifiers;
use PhpParser\Node;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt\Property;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
final class DowngradeAsymmetricVisibilityRector extends AbstractRector
{
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
'Remove asymmetric visibility modifiers (public private(set) -> public)',
[
new CodeSample(
'public private(set) float $x = 0.0;',
'public float $x = 0.0;',
),
],
);
}
/**
* @return array<class-string<Node>>
*/
public function getNodeTypes(): array
{
return [Property::class, Param::class];
}
public function refactor(Node $node): ?Node
{
\assert($node instanceof Property || $node instanceof Param);
if (($node->flags & Modifiers::VISIBILITY_SET_MASK) === 0) {
return null;
}
$node->flags &= ~Modifiers::VISIBILITY_SET_MASK;
if (($node->flags & Modifiers::VISIBILITY_MASK) === 0) {
$node->flags |= Modifiers::PUBLIC;
}
return $node;
}
}

A proteção no primeiro if é fundamental. Quando a propriedade não tem flag de visibilidade de escrita (set), a regra retorna null e deixa o nó inalterado. Uma regra que transforma incondicionalmente reescreveria código que ela deveria deixar intacto.

Cada regra tem um teste baseado em fixtures que executa a regra sobre arquivos .php.inc e verifica se a saída corresponde ao esperado. A infraestrutura de teste vem do Rector\Testing\PHPUnit\AbstractRectorTestCase do Rector.

Um caso de teste é pequeno e consistente entre as três regras existentes:

tests/Rector/DowngradeTraitConstantsRectorTest.php
<?php
declare(strict_types=1);
namespace NextPDF\Backport\Tests\Rector;
use Iterator;
use PHPUnit\Framework\Attributes\DataProvider;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
final class DowngradeTraitConstantsRectorTest extends AbstractRectorTestCase
{
#[DataProvider('provideData')]
public function test(string $filePath): void
{
$this->doTestFile($filePath);
}
public static function provideData(): Iterator
{
return self::yieldFilesFromDirectory(__DIR__ . '/Fixtures/DowngradeTraitConstants');
}
public function provideConfigFilePath(): string
{
return __DIR__ . '/config/downgrade_trait_constants.php';
}
}

O teste aponta para um arquivo de configuração por regra em tests/Rector/config/ que registra apenas a regra em teste, de modo que cada fixture exercite uma única regra de forma isolada:

tests/Rector/config/downgrade_trait_constants.php
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeTraitConstantsRector;
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withRules([
DowngradeTraitConstantsRector::class,
]);

Uma fixture é um arquivo .php.inc com a entrada, um separador ----- e a saída esperada. Quando a regra não faz nenhuma alteração, omita o separador e o segundo bloco. Uma fixture de transformação para a regra de constantes de trait fica assim:

tests/Rector/Fixtures/DowngradeTraitConstants/private_constant.php.inc
<?php
trait HasLimit
{
private const MAX_SIZE = 1024;
public function getLimit(): int
{
return self::MAX_SIZE;
}
}
?>
-----
<?php
trait HasLimit
{
private static $MAX_SIZE = 1024;
public function getLimit(): int
{
return self::$MAX_SIZE;
}
}
?>

Para criar os testes de uma nova regra:

  1. Crie tests/Rector/Fixtures/Downgrade<Feature>/ e adicione um .php.inc por caso.
  2. Cubra tanto os casos de transformação com um separador ----- quanto os casos de skip sem separador, como uma propriedade sem visibilidade de escrita (set) que deve permanecer inalterada.
  3. Adicione um tests/Rector/config/downgrade_<feature>.php que registra apenas a sua regra.
  4. Adicione um tests/Rector/Downgrade<Feature>RectorTest.php que itera sobre o diretório de fixtures e aponta para a configuração.
  5. Execute a suíte.
Terminal window
composer test

O repositório também inclui RectorRulesBehaviorTest e RectorRulesMetadataTest, que verificam o comportamento entre regras e confirmam que o getRuleDefinition() de cada regra está bem formado. Execute o composer test completo para que essas verificações incluam a sua nova regra.

Uma regra não fica ativa no build enquanto você não a registrar nos arquivos de configuração do pipeline. Há dois alvos de build, cada um com a sua própria configuração em rector/config/.

  1. Abra rector/config/rector-php81.php e adicione a classe da sua regra à lista ->withRules([...]).
  2. Se o recurso também precisar de downgrade para o build do core em PHP 7.4, adicione a mesma classe a rector/config/rector-php74.php.
  3. Adicione um comentário nomeando a versão do PHP que introduziu o recurso, no mesmo padrão das entradas existentes.
rector/config/rector-php81.php
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeAsymmetricVisibilityRector;
use NextPDF\Backport\DowngradeCloneWithRector;
use NextPDF\Backport\DowngradeTraitConstantsRector;
use Rector\Config\RectorConfig;
use Rector\DowngradePhp81\Rector\Property\DowngradeReadonlyPropertyRector;
return RectorConfig::configure()
->withDowngradeSets(php81: true)
->withRules([
// PHP 8.4 — asymmetric visibility (not covered by built-in sets)
DowngradeAsymmetricVisibilityRector::class,
// PHP 8.4 — clone-with syntax (not covered by built-in sets)
DowngradeCloneWithRector::class,
// PHP 8.2 — trait constants (not covered by built-in sets)
DowngradeTraitConstantsRector::class,
// PHP 8.1 — readonly property removal (for clone-with expansion safety)
DowngradeReadonlyPropertyRector::class,
]);

O orquestrador de build (scripts/build.php) mescla os repositórios de origem, executa o Rector com essas configurações, ajusta o composer.json gerado e executa uma verificação de sintaxe php -l sobre a saída. Valide a sua regra com o PHPStan e o build completo antes de depender dela.

Terminal window
composer analyse
composer build:dry
  • A ordem de registro não importa, mas a ordem das regras importa conceitualmente. O mecanismo de múltiplas passagens do Rector percorre o código novamente até que nenhuma regra faça outra alteração, então você não ordena as regras manualmente na configuração. Ainda assim, documente qualquer dependência de ordenação no docblock da classe, como DowngradeCloneWithRector faz: a expansão dele produz $clone->prop = $val, o que falharia em uma propriedade readonly, então DowngradeReadonlyPropertyRector deve ser executado para o mesmo alvo.
  • Passe atributos vazios ao construir um nó de substituição a partir de um tipo de nó diferente. DowngradeTraitConstantsRector constrói uma Property a partir de uma ClassConst e passa [] como atributos em vez dos atributos do nó de origem. Se você mantiver os atributos originais, deixará um ponteiro origNode apontando para o tipo de nó errado e disparará uma asserção no printer que preserva a formatação.
  • Reinicie o estado por arquivo na visita ao FileNode. DowngradeCloneWithRector declara FileNode::class em getNodeTypes() apenas para reiniciar o contador de variáveis temporárias no início de cada arquivo, de modo que os nomes de variáveis gerados não colidam entre arquivos.
  • Proteja com precisão e, em seguida, retorne null. Uma transformação clone-with deve confirmar que o nome da chamada é clone e que o segundo argumento é um literal de array antes de agir; um simples clone $obj nunca chega à regra como uma chamada de função, e uma chamada de dois argumentos cujo segundo argumento não é um array permanece intacta.
  • Remova os modificadores que o alvo não consegue expressar. Quando a regra de constantes de trait transforma uma constante em uma propriedade estática, ela preserva a visibilidade e adiciona static, mas não deve carregar um modificador final, porque propriedades do PHP 8.1 não podem ser final.
  • Mantenha a regra no PHPStan Level 10. O repositório executa composer analyse no level 10 sobre rector/rules e scripts. Tipifique todas as assinaturas e anote os formatos dos arrays; uma regra que não sobreviveria ao analisador é um defeito, não um rascunho.