跳转到内容

编写自定义 Rector 降级规则

NextPDF backport 流水线会将 nextpdf/nextpdf 的 PHP 8.4 源代码降级,使其可在 PHP 8.1(以及核心部分的 PHP 7.4)上运行。对于大多数语言特性,它使用 Rector 内置的降级规则集,并配合一小组自定义规则,处理 Rector 原生不支持的特性。

本指南会说明如何添加一条新的自定义规则。其结构沿用仓库中已有的三条规则:DowngradeAsymmetricVisibilityRectorDowngradeCloneWithRectorDowngradeTraitConstantsRector。这三条规则都位于 rector/rules/,在 rector/config/ 中注册,并由 tests/Rector/ 中基于 fixture 的测试覆盖。

当某个 NextPDF 特性使用了构建目标不支持的 PHP 语法,而 Rector 又没有内置降级方案时,请使用本指南。开始之前,请先确认 Rector 确实缺少相应规则。内置的 withDowngradeSets() 调用链已经处理了 readonly 类、带类型的类常量、管道运算符以及许多其他特性。

你需要在 nextpdf-backport 仓库内编写和运行自定义规则。开发工具在该仓库的 composer.json 中声明。

  1. 克隆 backport 仓库并安装其依赖。
  2. 确认 Rector 和 PHPUnit 能够正常解析。
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

该仓库运行时需要 PHP 8.4(这些规则操作的是 8.4 语法树),即使构建输出的目标是 PHP 8.1 或 7.4。composer.jsonrequirephp 锁定为 >=8.4 <9.0。自定义规则在 NextPDF\Backport\ 命名空间下自动加载,映射到 rector/rules/;测试在 NextPDF\Backport\Tests\ 下自动加载,映射到 tests/

每条自定义规则都继承 Rector\Rector\AbstractRector 并实现三个方法。现有三条规则的契约相同。

成员类型用途
getRuleDefinition()RuleDefinition面向人的描述,加上供规则文档生成器使用的 before/after CodeSample 配对。
getNodeTypes()array<class-string<Node>>规则要访问的抽象语法树 (AST) 节点类。Rector 只会对这些节点调用 refactor()
refactor(Node $node)?Node 或 `Stmt[]null`

节点类型声明驱动着一切。DowngradeAsymmetricVisibilityRector[Property::class, Param::class] 为目标,因为非对称可见性 (public private(set)) 既可能出现在类属性上,也可能出现在构造函数提升参数上。DowngradeTraitConstantsRector[Trait_::class] 为目标,因为它会一次性重写整个 trait 主体。DowngradeCloneWithRector[FileNode::class, Return_::class, Expression::class] 为目标,因为 clone-with (clone($obj, [...])) 同时出现在 return 和赋值处;它利用对 FileNode 的访问来重置一个逐文件计数器。

refactor() 返回 null 表示「无改动」。返回一个节点表示执行替换。返回 Stmt[](一个语句列表)的规则会把一条语句展开成多条。DowngradeCloneWithRector 正是这样把单条 return clone($this, [...]); 转换成一次克隆赋值、每个被覆盖项一次属性赋值,以及最后一条 return

两份流水线配置分别调用 ->withDowngradeSets(php81: true)->withDowngradeSets(php74: true)。这些规则集会为目标版本串联每一条内置降级规则。自定义规则只是用来填补这些缺口:PHP 8.4 的非对称可见性、PHP 8.5 的 clone-with,以及 PHP 8.2 的 trait 常量。这些特性 Rector 都不会自行降级。只有在确认存在同样缺口之后,才编写自定义规则。

以下流程用于添加一条新规则。全文示例沿用现有规则的形态;请替换成你自己的特性。

  1. rector/rules/ 中创建规则类。将其命名为 Downgrade<Feature>Rector,并放入 NextPDF\Backport 命名空间,使现有自动加载映射能够识别到它。
  2. 继承 Rector\Rector\AbstractRector,并将该类标记为 final
  3. 实现 getNodeTypes(),返回规则所需的最窄一组 AST 节点类。集合越窄,Rector 访问的节点就越少。
  4. 实现 refactor()。断言节点属于声明类型之一,对你所瞄准的精确语法进行防护,转换它,然后返回新节点(或 Stmt[],或用 null 表示无改动)。
  5. 实现 getRuleDefinition(),为规则处理的每种不同情形提供一对 before/after CodeSample
  6. 让文件保持在 PHPStan Level 10:每个参数、返回值和属性都带类型,并用 PHPDoc 泛型描述数组形状。

非对称可见性规则是最小的完整示例。它会移除 set 可见性标志,并确保保留一个基本的读可见性:

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;
}
}

第一个 if 上的防护是关键。属性没有 set 可见性标志时,规则返回 null 并保持该节点不变。无条件转换的规则会改写它本不应触碰的代码。

每条规则都有一个基于 fixture 的测试,针对 .php.inc 文件运行该规则,并断言输出与预期相符。这套测试框架来自 Rector 的 Rector\Testing\PHPUnit\AbstractRectorTestCase

测试用例很小,三条现有规则的写法也一致:

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';
}
}

测试会指向 tests/Rector/config/ 中的一份单规则配置文件;该文件只注册被测规则,从而让每个 fixture 都能在隔离状态下只演练一条规则:

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,
]);

每个 fixture 都是一个 .php.inc 文件,包含输入、一个 ----- 分隔符,以及预期输出。当规则不产生改动时,省略分隔符和第二个代码块。trait 常量规则中一个会触发转换的 fixture 如下:

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;
}
}
?>

要为新规则编写测试:

  1. 创建 tests/Rector/Fixtures/Downgrade<Feature>/,并为每种情形添加一个 .php.inc
  2. 既要覆盖会触发转换的情形(带 ----- 分隔符),也要覆盖应被跳过的情形(无分隔符);例如,没有 set 可见性的属性必须原样通过,不得被改动。
  3. 添加一份只注册你这条规则的 tests/Rector/config/downgrade_<feature>.php
  4. 添加一个 tests/Rector/Downgrade<Feature>RectorTest.php,它产出 fixture 目录并指向该配置。
  5. 运行测试套件。
Terminal window
composer test

仓库还包含 RectorRulesBehaviorTestRectorRulesMetadataTest,它们会断言跨规则的行为,以及每条规则的 getRuleDefinition() 格式良好。运行完整的 composer test,让这些检查也覆盖你的新规则。

一条规则注册到流水线配置之前,不会在构建中生效。这里有两个构建目标,每个目标在 rector/config/ 中都有自己的配置。

  1. 打开 rector/config/rector-php81.php,把你的规则类添加到 ->withRules([...]) 列表中。
  2. 如果该特性还必须为 PHP 7.4 核心构建降级,就把同一个类也添加到 rector/config/rector-php74.php
  3. 添加一条注释,写明引入该特性的 PHP 版本,与现有条目保持一致。
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,
]);

构建编排器 (scripts/build.php) 会合并源代码仓库,使用这些配置运行 Rector,调整生成的 composer.json,并对输出运行一次 php -l 语法检查。让构建依赖你的规则之前,先用 PHPStan 和完整构建对它进行验证。

Terminal window
composer analyse
composer build:dry
  • 注册顺序无关紧要,但规则顺序在概念上仍有意义。 Rector 的多趟机制会反复遍历,直到没有规则继续产生改动,因此你无需在配置中手动排序规则。即便如此,也要像 DowngradeCloneWithRector 那样,在类的 docblock 中记录任何顺序依赖:它的展开会产生 $clone->prop = $val,而这在 readonly 属性上会失败,因此 DowngradeReadonlyPropertyRector 必须针对同一目标运行。
  • 从另一种节点类型构建替换节点时,传入空属性。 DowngradeTraitConstantsRector 会构建出一个 Property(源自一个 ClassConst),并为属性传入 [] 而非源节点的属性。沿用源节点属性会让一个 origNode 指针指向错误的节点类型,并在保留格式打印器中触发一个断言。
  • 访问 FileNode 时重置逐文件状态。 DowngradeCloneWithRectorgetNodeTypes() 中声明 FileNode::class,纯粹是为了在每个文件开始时重置其临时变量计数器,使生成的变量名不会跨文件冲突。
  • 精确防护,然后返回 null clone-with 转换必须先确认调用名是 clone,且第二个参数是数组字面量,才能动手;普通的 clone $obj 永远不会作为函数调用进入该规则,而第二个参数不是数组的双参数函数调用则会被放过不动。
  • 剥离目标版本无法表达的修饰符。 当 trait 常量规则把一个常量转换成静态属性时,它会保留可见性并加上 static,但绝不能带上 final 修饰符,因为 PHP 8.1 属性不能声明为 final。
  • 让规则保持在 PHPStan Level 10。 该仓库以 level 10 运行 composer analyse,覆盖 rector/rulesscripts。为每个签名标注类型,并为数组形状添加注解;一条经不起分析器检验的规则是缺陷,而非草稿。
  • Backport Builder 开发者指南 —— 围绕这些规则的流水线架构、分支模型和发布工件。
  • Backport API 参考 —— backport 构建工具所发布的接口。
  • Backport 配置 —— 构建目标与降级规则集的选择。
  • Backport 故障排查 —— 诊断生成的降级树中的失败。
  • Rector 文档 —— AbstractRectorRuleDefinition,以及 getNodeTypes() 中所用 AST 节点类的上游参考。