Writing a custom sniff for PHP CodeSniffer
Published by Matthew Daly at 13th January 2019 6:50 pm
I've recently come around to the idea that in PHP all classes should be final by default, and have started doing so as a matter of course. However, when you start doing something like this it's easy to miss a few files that haven't been updated, or forget to do it, so I wanted a way to detect PHP classes that are not set as either abstract or final, and if possible, set them as final automatically. I've mentioned before that I use PHP CodeSniffer extensively, and that has the capability to both find and resolve deviations from a coding style, so last night I started looking into the possibility of creating a coding standard for this. It took a little work to understand how to do this so I thought I'd use this sniff as a simple example.
The first part is to set out the directory structure. There's a very specific layout you have to follow for PHP CodeSniffer:
- The folder for the standard must have the name of the standard, and be in the source folder set by Composer (in this case,
src/AbstractOrFinalClassesOnly
. - This folder must contain a
ruleset.xml
file defining the name and description of the standard, and any other required content. - Any defined sniffs must be in a
Sniffs
folder.
The ruleset.xml
file was fairly simple in this case, as this is a very simple standard:
1<?xml version="1.0"?>2<ruleset name="AbstractOrFinalClassesOnly">3 <description>Checks all classes are marked as either abstract or final.</description>4</ruleset>
The sniff is intended to do the following:
- Check all classes have either the
final
keyword or theabstract
keyword set - When running the fixer, make all classes without the
abstract
keyword final
First of all, our class must implement the interface PHP_CodeSniffer\Sniffs\Sniff
, which requires the following methods:
1 public function register(): array;23 public function process(File $file, $position): void;
Note that File
here is an instance of PHP_CodeSniffer\Files\File
. The first method registers the code the sniff should operate on. Here we're only interested in classes, so we return an array containing T_CLASS
. This is defined in the list of parser tokens used by PHP, and represents classes and objects:
1 public function register(): array2 {3 return [T_CLASS];4 }
For the process()
method, we receive two arguments, the file itself, and the position. We need to keep a record of the tokens we check for, so we do so in a private property:
1 private $tokens = [2 T_ABSTRACT,3 T_FINAL,4 ];
Then, we need to find the error:
1 if (!$file->findPrevious($this->tokens, $position)) {2 $file->addFixableError(3 'All classes should be declared using either the "abstract" or "final" keyword',4 $position - 1,5 self::class6 );7 }
We use $file
to get the token before class
, and pass the $tokens
property as a list of acceptable values. If the preceding token is not either abstract
or final
, we add a fixable error. The first argument is the string error message, the second is the location, and the third is the class of the sniff that has failed.
That will catch the issue, but won't actually fix it. To do that, we need to get the fixer from the file object, and call its addContent()
method to add the final
keyword. We amend process()
to extract the fixer, add it as a property, and then call the fix()
method when we come across a fixable error:
1 public function process(File $file, $position): void2 {3 $this->fixer = $file->fixer;4 $this->position = $position;56 if (!$file->findPrevious($this->tokens, $position)) {7 $file->addFixableError(8 'All classes should be declared using either the "abstract" or "final" keyword',9 $position - 1,10 self::class11 );12 $this->fix();13 }14 }
Then we define the fix()
method:
1 private function fix(): void2 {3 $this->fixer->addContent($this->position - 1, 'final ');4 }
Here's the finished class:
1<?php declare(strict_types=1);23namespace Matthewbdaly\AbstractOrFinalClassesOnly\Sniffs;45use PHP_CodeSniffer\Sniffs\Sniff;6use PHP_CodeSniffer\Files\File;78/**9 * Sniff for catching classes not marked as abstract or final10 */11final class AbstractOrFinalSniff implements Sniff12{13 private $tokens = [14 T_ABSTRACT,15 T_FINAL,16 ];1718 private $fixer;1920 private $position;2122 public function register(): array23 {24 return [T_CLASS];25 }2627 public function process(File $file, $position): void28 {29 $this->fixer = $file->fixer;30 $this->position = $position;3132 if (!$file->findPrevious($this->tokens, $position)) {33 $file->addFixableError(34 'All classes should be declared using either the "abstract" or "final" keyword',35 $position - 1,36 self::class37 );38 $this->fix();39 }40 }4142 private function fix(): void43 {44 $this->fixer->addContent($this->position - 1, 'final ');45 }46}
I've made the resulting standard available via Github.
This is a bit rough and ready and I'll probably refactor it a bit when I have time. In addition, it's not quite displaying the behaviour I want as it should, since ideally it should only be looking for the abstract
and final
keywords in classes that implement an interface. However, it's proven fairly easy to create this sniff, except for the fact I had to go rooting around various tutorials that weren't all that clear. Hopefully this example is a bit simpler and easier to follow.