-
Notifications
You must be signed in to change notification settings - Fork 1
4. Writing your own rule
A rule is a small class under src/Rules/ that extends Rector\Rector\AbstractRector and answers
three questions: which node types do I care about, how do I transform a matching node, and
what's my documentation example.
<?php
declare(strict_types=1);
namespace Xoops\Rector\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
final class MyExampleRector extends AbstractRector
{
/** Which AST node classes Rector should hand to refactor(). Narrow = faster. */
public function getNodeTypes(): array
{
return [FuncCall::class];
}
/** Return a new/mutated node to replace $node, or null to leave it unchanged. */
public function refactor(Node $node): ?Node
{
if (!$node instanceof FuncCall || !$node->name instanceof Name) {
return null;
}
if ($node->name->toLowerString() !== 'old_function') {
return null;
}
$node->name = new Name('new_function');
return $node;
}
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
'Renames old_function() to new_function().',
[new CodeSample('old_function($x);', 'new_function($x);')]
);
}
}-
Match precisely, bail early. Re-check the instance and every shape assumption at the top of
refactor()andreturn nullthe moment something doesn't fit. A loose rule is how you corrupt code. -
Idempotency / fix-point. Rector runs every rule repeatedly until nothing changes. Your output
must not re-match your own rule, or you get an infinite loop. (E.g. after renaming
old→new, the node is nownew, which your check skips.) -
?Nodereturn. Return the changed node (mutated or freshly built) to replace it, ornullto skip. To remove a statement, return\PhpParser\NodeVisitor::REMOVE_NODE(widen the return type toint|null— seeRemoveFuncCallRector). -
Find the node classes you need by running Rector with
--debug, or read php-parser'sPhpParser\Node\*(Expr, Stmt, Scalar). Common ones:FuncCall,MethodCall,StaticCall,New_,Assign,ArrayDimFetch,Variable,Name,Identifier,Scalar\String_,Scalar\Int_,Expr\ConstFetch. -
Case-insensitivity. Function/method names are case-insensitive in PHP. Compare with
->toLowerString()(onName) orstrtolower(...).
To accept configuration from the set file, implement ConfigurableRectorInterface and a configure()
method (see RemoveFuncCallRector, RenameMethodCallByNameRector, RenameMethodWithAddedFirstArgRector,
ServerSuperglobalToXmfRequestRector):
use Rector\Contract\Rector\ConfigurableRectorInterface;
final class RenameMethodCallByNameRector extends AbstractRector implements ConfigurableRectorInterface
{
/** @var array<string,string> */
private array $map = [];
/** @param mixed $configuration */
public function configure($configuration): void
{
$this->map = is_array($configuration) ? $configuration : [];
}
// … refactor() reads $this->map …
}Configured in the set with ruleWithConfiguration():
$rectorConfig->ruleWithConfiguration(RenameMethodCallByNameRector::class, [
'oldMethod' => 'newMethod',
]);Cross-cutting helpers live in src/Support/. Two examples already in the package:
-
SqlKeywordDetector::isWriteSql(?Arg $arg): bool— classifies a SQL literal as read vs write (used by the DB-routing rules). -
MytsReceiverDetector::isMytsReceiver(Expr $var): bool— true only for$myts/$GLOBALS['myts'](used by the receiver-aware MyTS rules).
use the trait in your rule rather than copying the logic.
Add it to the appropriate set:
// config/sets/xoops.php (behaviour-preserving) — or config/sets/xoops-risky.php
$rectorConfig->rules([
// …
\Xoops\Rector\Rules\MyExampleRector::class,
]);Decide which set. If the rule is behaviour-preserving or fixes outright-broken (removed-function)
code → default XOOPS set. If it changes runtime values, escaping, or output → XOOPS_RISKY.
The fastest loop is a fixture: a tiny "before" snippet plus a one-rule config file. This works the same on PowerShell and bash (no Unix-only process substitution):
-
fix.php— the "before":<?php $x = old_function(1);
-
rector-fixture.php— a throwaway config enabling just your rule:<?php use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths([__DIR__ . '/fix.php']) ->withRules([\Xoops\Rector\Rules\MyExampleRector::class]);
- Run it — and run it twice. The second pass must be a no-op (proves your rule is idempotent):
vendor/bin/rector process --config=rector-fixture.php --dry-run
Then keep the package green:
composer analyse # PHPStan
composer cs:fix # PHP-CS-FixerThe CI workflow (.github/workflows/ci.yml) runs composer validate + CS + PHPStan + an autoload
smoke test (every rule class + the set list resolve) across PHP 8.2/8.3/8.4 on every push.