php

Writing a custom sniff for PHP CodeSniffer

matthewbdaly

Matthew Daly

Posted on January 20, 2019

Writing a custom sniff for PHP CodeSniffer

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:

<?xml version="1.0"?>
<ruleset name="AbstractOrFinalClassesOnly">
    <description>Checks all classes are marked as either abstract or final.</description>
</ruleset>

Enter fullscreen mode Exit fullscreen mode

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:

    public function register(): array;

    public function process(File $file, $position): void;

Enter fullscreen mode Exit fullscreen mode

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:

    public function register(): array
    {
        return [T_CLASS];
    }

Enter fullscreen mode Exit fullscreen mode

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:

    private $tokens = [
        T_ABSTRACT,
        T_FINAL,
    ];

Enter fullscreen mode Exit fullscreen mode

Then, we need to find the error:

        if (!$file->findPrevious($this->tokens, $position)) {
            $file->addFixableError(
                'All classes should be declared using either the "abstract" or "final" keyword',
                $position - 1,
                self::class
            );
        }

Enter fullscreen mode Exit fullscreen mode

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:

    public function process(File $file, $position): void
    {
        $this->fixer = $file->fixer;
        $this->position = $position;

        if (!$file->findPrevious($this->tokens, $position)) {
            $file->addFixableError(
                'All classes should be declared using either the "abstract" or "final" keyword',
                $position - 1,
                self::class
            );
            $this->fix();
        }
    }

Enter fullscreen mode Exit fullscreen mode

Then we define the fix() method:

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

Enter fullscreen mode Exit fullscreen mode

Here’s the finished class:

<?php declare(strict_types=1);

namespace Matthewbdaly\AbstractOrFinalClassesOnly\Sniffs;

use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Files\File;

/**
 * Sniff for catching classes not marked as abstract or final
 */
final class AbstractOrFinalSniff implements Sniff
{
    private $tokens = [
        T_ABSTRACT,
        T_FINAL,
    ];

    private $fixer;

    private $position;

    public function register(): array
    {
        return [T_CLASS];
    }

    public function process(File $file, $position): void
    {
        $this->fixer = $file->fixer;
        $this->position = $position;

        if (!$file->findPrevious($this->tokens, $position)) {
            $file->addFixableError(
                'All classes should be declared using either the "abstract" or "final" keyword',
                $position - 1,
                self::class
            );
            $this->fix();
        }
    }

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

Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
matthewbdaly
Matthew Daly

Posted on January 20, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related