跳到內容

撰寫自訂 Rector 降階規則

NextPDF 的 backport 管線會把 nextpdf/nextpdf 的 PHP 8.4 原始碼降階,使其能在 PHP 8.1 上執行(核心套件還要能在 PHP 7.4 上執行)。對大多數語言特性而言,它會使用 Rector 內建的降階集(downgrade set),再搭配一小組自訂規則,處理 Rector 本身尚未涵蓋的特性。

本指南示範如何新增一條自訂規則。它沿用儲存庫中既有三條規則的結構:DowngradeAsymmetricVisibilityRectorDowngradeCloneWithRector,以及 DowngradeTraitConstantsRector。這三條規則都放在 rector/rules/,在 rector/config/ 中註冊,並由 tests/Rector/ 中以 fixture 為基礎的測試涵蓋。

當某項 NextPDF 特性使用了建置目標不支援的 PHP 語法,而 Rector 又沒有內建降階規則時,就採用本指南。動手之前,請先確認 Rector 確實缺少對應規則。內建的 withDowngradeSets() 鏈已經能處理 readonly 類別、具型別的類別常數、pipe 運算子,以及其他許多特性。

你會在 nextpdf-backport 儲存庫內撰寫並執行自訂規則。開發工具宣告在該儲存庫的 composer.json 中。

  1. 複製 backport 儲存庫並安裝它的相依套件。
  2. 確認 Rector 與 PHPUnit 都能正確 resolve(解析)。
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.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 都不會自行降階。確認真的是同樣的缺口之後,再動手撰寫自訂規則。

此流程會新增一條規則。示範範例沿用現有規則的形式;請替換成你自己的特性。

  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/ 中一份逐規則的 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 目錄並指向該 config。
  5. 執行整套測試。
Terminal window
composer test

這個儲存庫還帶有 RectorRulesBehaviorTestRectorRulesMetadataTest,用來斷言跨規則的行為,以及每條規則的 getRuleDefinition() 格式是否正確。執行完整的 composer test,讓這些檢查關卡看得到你的新規則。

規則在被註冊進管線 config 之前,不會在建置中生效。共有兩個建置目標,各自在 rector/config/ 中有自己的 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)會合併原始碼儲存庫、以這些 config 執行 Rector、調整產生的 composer.json,並對輸出執行 php -l 語法檢查。在你依賴這條規則之前,先用 PHPStan 與完整建置驗證它。

Terminal window
composer analyse
composer 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/rulesscripts。為每個簽章標上型別並註記陣列形狀;一條過不了分析器的規則是瑕疵,不是草稿。