编写自定义 Rector 降级规则
NextPDF backport 流水线会将 nextpdf/nextpdf 的 PHP 8.4 源代码降级,使其可在 PHP 8.1(以及核心部分的 PHP 7.4)上运行。对于大多数语言特性,它使用 Rector 内置的降级规则集,并配合一小组自定义规则,处理 Rector 原生不支持的特性。
本指南会说明如何添加一条新的自定义规则。其结构沿用仓库中已有的三条规则:DowngradeAsymmetricVisibilityRector、DowngradeCloneWithRector 和 DowngradeTraitConstantsRector。这三条规则都位于 rector/rules/,在 rector/config/ 中注册,并由 tests/Rector/ 中基于 fixture 的测试覆盖。
当某个 NextPDF 特性使用了构建目标不支持的 PHP 语法,而 Rector 又没有内置降级方案时,请使用本指南。开始之前,请先确认 Rector 确实缺少相应规则。内置的 withDowngradeSets() 调用链已经处理了 readonly 类、带类型的类常量、管道运算符以及许多其他特性。
你需要在 nextpdf-backport 仓库内编写和运行自定义规则。开发工具在该仓库的 composer.json 中声明。
- 克隆 backport 仓库并安装其依赖。
- 确认 Rector 和 PHPUnit 能够正常解析。
git clone https://github.com/nextpdf-labs/backport.gitcd backportcomposer installvendor/bin/rector --versionvendor/bin/phpunit --version该仓库运行时需要 PHP 8.4(这些规则操作的是 8.4 语法树),即使构建输出的目标是 PHP 8.1 或 7.4。composer.jsonrequire 将 php 锁定为 >=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 都不会自行降级。只有在确认存在同样缺口之后,才编写自定义规则。
分步操作:编写一条规则
标题为“分步操作:编写一条规则”的章节以下流程用于添加一条新规则。全文示例沿用现有规则的形态;请替换成你自己的特性。
- 在
rector/rules/中创建规则类。将其命名为Downgrade<Feature>Rector,并放入NextPDF\Backport命名空间,使现有自动加载映射能够识别到它。 - 继承
Rector\Rector\AbstractRector,并将该类标记为final。 - 实现
getNodeTypes(),返回规则所需的最窄一组 AST 节点类。集合越窄,Rector 访问的节点就越少。 - 实现
refactor()。断言节点属于声明类型之一,对你所瞄准的精确语法进行防护,转换它,然后返回新节点(或Stmt[],或用null表示无改动)。 - 实现
getRuleDefinition(),为规则处理的每种不同情形提供一对 before/afterCodeSample。 - 让文件保持在 PHPStan Level 10:每个参数、返回值和属性都带类型,并用 PHPDoc 泛型描述数组形状。
非对称可见性规则是最小的完整示例。它会移除 set 可见性标志,并确保保留一个基本的读可见性:
<?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 与测试
标题为“Fixture 与测试”的章节每条规则都有一个基于 fixture 的测试,针对 .php.inc 文件运行该规则,并断言输出与预期相符。这套测试框架来自 Rector 的 Rector\Testing\PHPUnit\AbstractRectorTestCase。
测试用例很小,三条现有规则的写法也一致:
<?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 都能在隔离状态下只演练一条规则:
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeTraitConstantsRector;use Rector\Config\RectorConfig;
return RectorConfig::configure() ->withRules([ DowngradeTraitConstantsRector::class, ]);每个 fixture 都是一个 .php.inc 文件,包含输入、一个 ----- 分隔符,以及预期输出。当规则不产生改动时,省略分隔符和第二个代码块。trait 常量规则中一个会触发转换的 fixture 如下:
<?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; }}?>要为新规则编写测试:
- 创建
tests/Rector/Fixtures/Downgrade<Feature>/,并为每种情形添加一个.php.inc。 - 既要覆盖会触发转换的情形(带
-----分隔符),也要覆盖应被跳过的情形(无分隔符);例如,没有 set 可见性的属性必须原样通过,不得被改动。 - 添加一份只注册你这条规则的
tests/Rector/config/downgrade_<feature>.php。 - 添加一个
tests/Rector/Downgrade<Feature>RectorTest.php,它产出 fixture 目录并指向该配置。 - 运行测试套件。
composer test仓库还包含 RectorRulesBehaviorTest 和 RectorRulesMetadataTest,它们会断言跨规则的行为,以及每条规则的 getRuleDefinition() 格式良好。运行完整的 composer test,让这些检查也覆盖你的新规则。
接入构建
标题为“接入构建”的章节一条规则注册到流水线配置之前,不会在构建中生效。这里有两个构建目标,每个目标在 rector/config/ 中都有自己的配置。
- 打开
rector/config/rector-php81.php,把你的规则类添加到->withRules([...])列表中。 - 如果该特性还必须为 PHP 7.4 核心构建降级,就把同一个类也添加到
rector/config/rector-php74.php。 - 添加一条注释,写明引入该特性的 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 和完整构建对它进行验证。
composer analysecomposer build:dry边界情形与陷阱
标题为“边界情形与陷阱”的章节- 注册顺序无关紧要,但规则顺序在概念上仍有意义。 Rector 的多趟机制会反复遍历,直到没有规则继续产生改动,因此你无需在配置中手动排序规则。即便如此,也要像
DowngradeCloneWithRector那样,在类的 docblock 中记录任何顺序依赖:它的展开会产生$clone->prop = $val,而这在 readonly 属性上会失败,因此DowngradeReadonlyPropertyRector必须针对同一目标运行。 - 从另一种节点类型构建替换节点时,传入空属性。
DowngradeTraitConstantsRector会构建出一个Property(源自一个ClassConst),并为属性传入[]而非源节点的属性。沿用源节点属性会让一个origNode指针指向错误的节点类型,并在保留格式打印器中触发一个断言。 - 访问
FileNode时重置逐文件状态。DowngradeCloneWithRector在getNodeTypes()中声明FileNode::class,纯粹是为了在每个文件开始时重置其临时变量计数器,使生成的变量名不会跨文件冲突。 - 精确防护,然后返回
null。 clone-with 转换必须先确认调用名是clone,且第二个参数是数组字面量,才能动手;普通的clone $obj永远不会作为函数调用进入该规则,而第二个参数不是数组的双参数函数调用则会被放过不动。 - 剥离目标版本无法表达的修饰符。 当 trait 常量规则把一个常量转换成静态属性时,它会保留可见性并加上
static,但绝不能带上final修饰符,因为 PHP 8.1 属性不能声明为 final。 - 让规则保持在 PHPStan Level 10。 该仓库以 level 10 运行
composer analyse,覆盖rector/rules和scripts。为每个签名标注类型,并为数组形状添加注解;一条经不起分析器检验的规则是缺陷,而非草稿。
另请参阅
标题为“另请参阅”的章节- Backport Builder 开发者指南 —— 围绕这些规则的流水线架构、分支模型和发布工件。
- Backport API 参考 —— backport 构建工具所发布的接口。
- Backport 配置 —— 构建目标与降级规则集的选择。
- Backport 故障排查 —— 诊断生成的降级树中的失败。
- Rector 文档 ——
AbstractRector、RuleDefinition,以及getNodeTypes()中所用 AST 节点类的上游参考。