Writing a custom sniff for PHP CodeSniffer

Published by 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>

The sniff is intended to do the following:

  • Check all classes have either the final keyword or the abstract 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;
3 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(): array
2 {
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 = [
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::class
6 );
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): void
2 {
3 $this->fixer = $file->fixer;
4 $this->position = $position;
6 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::class
11 );
12 $this->fix();
13 }
14 }

Then we define the fix() method:

1 private function fix(): void
2 {
3 $this->fixer->addContent($this->position - 1, 'final ');
4 }

Here's the finished class:

1<?php declare(strict_types=1);
3namespace Matthewbdaly\AbstractOrFinalClassesOnly\Sniffs;
5use PHP_CodeSniffer\Sniffs\Sniff;
6use PHP_CodeSniffer\Files\File;
9 * Sniff for catching classes not marked as abstract or final
10 */
11final class AbstractOrFinalSniff implements Sniff
13 private $tokens = [
16 ];
18 private $fixer;
20 private $position;
22 public function register(): array
23 {
24 return [T_CLASS];
25 }
27 public function process(File $file, $position): void
28 {
29 $this->fixer = $file->fixer;
30 $this->position = $position;
32 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::class
37 );
38 $this->fix();
39 }
40 }
42 private function fix(): void
43 {
44 $this->fixer->addContent($this->position - 1, 'final ');
45 }

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.