撰寫自訂 Rector 降階規則
重點速覽
標題為「重點速覽」的區段NextPDF 的 backport 管線會把 nextpdf/nextpdf 的 PHP 8.4 原始碼降階,使其能在 PHP 8.1 上執行(核心套件還要能在 PHP 7.4 上執行)。對大多數語言特性而言,它會使用 Rector 內建的降階集(downgrade set),再搭配一小組自訂規則,處理 Rector 本身尚未涵蓋的特性。
本指南示範如何新增一條自訂規則。它沿用儲存庫中既有三條規則的結構:DowngradeAsymmetricVisibilityRector、DowngradeCloneWithRector,以及 DowngradeTraitConstantsRector。這三條規則都放在 rector/rules/,在 rector/config/ 中註冊,並由 tests/Rector/ 中以 fixture 為基礎的測試涵蓋。
當某項 NextPDF 特性使用了建置目標不支援的 PHP 語法,而 Rector 又沒有內建降階規則時,就採用本指南。動手之前,請先確認 Rector 確實缺少對應規則。內建的 withDowngradeSets() 鏈已經能處理 readonly 類別、具型別的類別常數、pipe 運算子,以及其他許多特性。
你會在 nextpdf-backport 儲存庫內撰寫並執行自訂規則。開發工具宣告在該儲存庫的 composer.json 中。
- 複製 backport 儲存庫並安裝它的相依套件。
- 確認 Rector 與 PHPUnit 都能正確 resolve(解析)。
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>> | 規則想造訪的抽象語法樹(Abstract Syntax Tree,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, [...]); 轉成一次 clone 指派、每個覆寫項各一條屬性指派,以及最後一條 return。
內建集已經涵蓋哪些項目
標題為「內建集已經涵蓋哪些項目」的區段兩份管線 config 分別呼叫 ->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/ 中一份逐規則的 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 目錄並指向該 config。 - 執行整套測試。
composer test這個儲存庫還帶有 RectorRulesBehaviorTest 與 RectorRulesMetadataTest,用來斷言跨規則的行為,以及每條規則的 getRuleDefinition() 格式是否正確。執行完整的 composer test,讓這些檢查關卡看得到你的新規則。
接進建置流程
標題為「接進建置流程」的區段規則在被註冊進管線 config 之前,不會在建置中生效。共有兩個建置目標,各自在 rector/config/ 中有自己的 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)會合併原始碼儲存庫、以這些 config 執行 Rector、調整產生的 composer.json,並對輸出執行 php -l 語法檢查。在你依賴這條規則之前,先用 PHPStan 與完整建置驗證它。
composer analysecomposer build:dry邊界情況與陷阱
標題為「邊界情況與陷阱」的區段- 註冊順序無關緊要,但在概念上規則的順序仍有意義。 Rector 的多趟(multi-pass)機制會反覆走訪,直到沒有任何規則再造成變更為止,因此你不需要在 config 中手動排序規則。即便如此,仍要把任何順序相依性記錄在類別的 docblock 中,就像
DowngradeCloneWithRector所做的那樣:它的展開會產生$clone->prop = $val,而這在 readonly 屬性上會失敗,所以DowngradeReadonlyPropertyRector必須對同一個目標執行。 - 從不同種類的節點建構替換節點時,傳入空的 attributes。
DowngradeTraitConstantsRector會建構一個Property(其來源是一個ClassConst),並對 attributes 傳入[],而非沿用來源節點的 attributes。沿用原本的 attributes 會留下一個指向錯誤節點種類的origNode指標,並在保留格式的列印器中觸發斷言錯誤。 - 在造訪
FileNode時重設每個檔案的狀態。DowngradeCloneWithRector宣告FileNode::class(在getNodeTypes()中),純粹是為了在每個檔案開頭重設它的暫存變數計數器,讓產生的變數名稱不會跨檔案撞名。 - 精確設下守衛條件,再回傳
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 節點類別的上游參考來源。