Updated with ECS 12 and ECSConfig::configure()
simple way to work with configs.
You already know how coding standard tools work with tokens and how to write a Sniff.
Today we'll explore another tool - PHP CS Fixer and we get from finding the smelly spot to fixing it.
Are you new to PHP Coding Standard Tools? You can read intro How PHP Coding Standard Tools Actually Work to grasp the idea behind them. Or just go on if you're ready to start...
When a coding standard tool finds over 1000 violations in our code is nice to know, but it doesn't save us any time and energy we need for a deep work.
That main difference of PHP CS Fixer to PHP_CodeSniffer is that every Fixer has to fix issues it finds. That's why there is no LineLengthFixer
, because fixing line length is difficult to automate.
Personally I like PHP CS Fixer a bit more, because of more friendlier API, active community and openness to 3rd party packages:
Apart that, they are similar: they share tokens, dispatcher and subscribers.
Yet still, working with tokens is counter intuitive to way we work with the code (class, method, property...), but I'll write about that later.
Now we jump to writing the Fixer class.
ExceptionNameFixer
"An exception class should have "Exception" suffix."
In last post, we made ExceptionNameSniff, that will:
Today we'll add one more step:
Create a fixer class and implement a PhpCsFixer\Fixer\FixerInterface
interface.
It covers 7 required methods, but most of them are easy one-liners:
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Tokenizer\Tokens;
use SplFileInfo;
final class ExceptionNameFixer implements FixerInterface
{
# first 5 methods are rutine and descriptive
public function getName(): string
{
}
public function getDefinition(): FixerDefinitionInterface
{
}
public function isRisky(): bool
{
}
public function supports(SplFileInfo $file): bool
{
}
public function getPriority(): int
{
}
# in last 2 methods, the magic happens :)
public function isCandidate(Tokens $tokens): bool
{
}
public function fix(SplFileInfo $file, Tokens $tokens): void
{
}
}
I start with implementing first 5 methods, to make the easy work first:
<?php
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Tokenizer\Tokens;
use SplFileInfo;
final class ExceptionNameFixer implements FixerInterface
{
public function getName(): string
{
return self::class;
}
// this methods return the error message
// and it might include a sample code, that would fix it
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Exception classes should have suffix "Exception".',
[
new CodeSample(
'<?php
class SomeClass extends Exception
{
}'
),
]
);
}
// if the fixer changes code behavior in any way, return "true"
// changing a class name is such case
public function isRisky(): bool
{
return true;
}
// in 99.9% this is true, since only *.php are passed
// you can detect specific names, e.g. "*Repository.php"
public function supports(SplFileInfo $file): bool
{
return true;
}
// it's used to order all fixers before running them
// `0` by default, higher value is first
public function getPriority(): int
{
return 0;
}
}
Now we get to more interesting parts. Method isCandidate(Tokens $tokens): bool
is like a subscriber. It gets all tokens of the file. We can check more than one token and create more strict conditions thanks to that:
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isAllTokenKindsFound([T_CLASS, T_EXTENDS, T_STRING]);
}
extends
token without class and its name is useless and not a code we want to match.
public function fix(SplFileInfo $file, Tokens $tokens): void
{
}
This methods get same tokens as isCandidate()
and the file info.
How to build a fixer?
T_EXTENDS
doesn't tell a lot.Let's take it one a by one:
A class that extends another class that has suffix "Exception".
There is a bit different paradigm compared to PHP_CodeSniffer. We don't get position of the extends
token, but all the tokens. Instead of investigating one token and it's relation to other, we need to iterate through all tokens and match them with conditions:
public function fix(SplFileInfo $file, Tokens $tokens): void
{
foreach ($tokens as $index => $token) {
// is there extends token?
if (! $token->isGivenKind(T_EXTENDS)) {
continue;
}
// is this exception class?
if (! $this->isException($tokens, $index)) {
continue;
}
}
}
How to detect an exception class?
Tokens
(like File
in PHP_CodeSniffer) has helper methods to make our life easier.
First of them is getNextMeaningfulToken()
, which skips spaces and comments and seeks for first useful one. In our case, after extends
we look for a parent class name.
private function isException(Tokens $tokens, int $index): bool
{
$parentClassNamePosition = $tokens->getNextMeaningfulToken($index);
// $tokens support array access - to get a token with some index, call $tokens[25]
$parentClassNameToken = $tokens[$parentClassNamePosition];
$parentClassName = $parentClassNameToken->getContent();
return $this->stringEndsWith($parentClassName, 'Exception');
}
private function stringEndsWith(string $name, string $needle): bool
{
return substr($name, -strlen($needle)) === $needle;
}
Back to iteration! When this passes, we know we have a class that extends an exception.
Do you know what we need to do now? You're right, we have to check its name. We can use another helper method: getPrevMeaningfulToken()
.
public function fix(SplFileInfo $file, Tokens $tokens): void
{
foreach ($tokens as $index => $token) {
// is there extends token?
if (! $token->isGivenKind(T_EXTENDS)) {
continue;
}
// is this exception class?
if (! $this->isException($tokens, $index)) {
continue;
}
// does this class ends with "Exception"?
$classNamePosition = (int) $tokens->getPrevMeaningfulToken($index);
// get the token
$classNameToken = $tokens[$classNamePosition];
// check its content
if ($this->stringEndsWith($classNameToken->getContent(), 'Exception')) {
continue;
}
}
}
Fixing is right to the point. To change a name, replace old name (T_STRING
Token
) with new Token
object with different value:
// Token(token type, value)
$tokens[$classNamePosition] = new Token([T_STRING, $classNameToken->getContent() . 'Exception']);
Is that it? Yea, that's it :)
<?php
namespace App\CodingStandard\Fixer;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
use SplFileInfo;
final class ExceptionNameFixer extends FixerInterface
{
public function getName(): string
{
return self::class;
}
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Exception classes should have suffix "Exception".',
[
new CodeSample(
'<?php
class SomeClass extends Exception
{
}'
),
]
);
}
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isAllTokenKindsFound([T_CLASS, T_EXTENDS, T_STRING]);
}
public function isRisky(): bool
{
return false;
}
public function supports(SplFileInfo $file): bool
{
return true;
}
public function getPriority(): int
{
return 0;
}
public function fix(SplFileInfo $file, Tokens $tokens): void
{
foreach ($tokens as $index => $token) {
if (! $token->isGivenKind(T_EXTENDS)) {
continue;
}
if (! $this->isException($tokens, $index)) {
continue;
}
$classNamePosition = (int) $tokens->getPrevMeaningfulToken($index);
$classNameToken = $tokens[$classNamePosition];
if ($this->stringEndsWith($classNameToken->getContent(), 'Exception')) {
continue;
}
$tokens[$classNamePosition] = new Token([T_STRING, $$classNameToken->getContent() . 'Exception']);
}
}
private function isException(Tokens $tokens, int $index): bool
{
$parentClassNamePosition = $tokens->getNextMeaningfulToken($index);
$parentClassNameToken = $tokens[$parentClassNamePosition];
$parentClassName = $this->getParentClassName($tokens, $index);
return $this->stringEndsWith($parentClassName, 'Exception');
}
private function stringEndsWith(string $name, string $needle): bool
{
return (substr($name, -strlen($needle)) === $needle);
}
}
Register fixer in ecs.php
:
// ecs.php
use Symplify\EasyCodingStandard\Config\ECSConfig;
use App\CodingStandard\Fixer\ExceptionNameFixer;
return ECSConfig::configure()
->withRules([
ExceptionNameFixer::class,
]);
And run:
vendor/bin/ecs check src
That was your first fixer.
Happy coding!
Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!