How do you write a custom PHP_CodeSniffer 4.0 sniff, and what do its register() and process() methods do?
answer
- implements PHP_CodeSniffer\Sniffs\Sniff
- Standard/Sniffs/Category/NameSniff.php
- register() returns token types
- process(File $phpcsFile, int $stackPtr)
- addFixableError plus a fixer changeset
basics
~10 sA custom sniff is a class implementing PHP_CodeSniffer\Sniffs\Sniff inside a standard's Sniffs/Category folder: register() returns the token types it listens for, and process() inspects each matching token and reports violations through the File object.
solid answer
~30 sYou create a standard directory with a `ruleset.xml` and a class such as `Acme\Sniffs\Dates\ImmutableDateSniff` in `Acme/Sniffs/Dates/ImmutableDateSniff.php`; the path gives the sniff code `Acme.Dates.ImmutableDate`. It implements `PHP_CodeSniffer\Sniffs\Sniff`. `register()` returns token constants, for example `[T_NEW]`. `process(File $phpcsFile, int $stackPtr)` runs for every matching token; it reads `$phpcsFile->getTokens()`, walks nearby tokens with `findNext()`, and calls `addError($message, $stackPtr, 'Mutable')`. For an automatic fix, call `addFixableError()`, and if it returns true, wrap `$phpcsFile->fixer->replaceToken()` in `beginChangeset()`/`endChangeset()`. Public properties become ruleset-configurable. PHP_CodeSniffer 4.0 rejects sniffs that do not implement the interface or follow the naming convention, and tokenizes namespaced names as `T_NAME_QUALIFIED`-style tokens.
code
php · 26 lines<?php
declare(strict_types=1);
namespace Acme\Sniffs\Dates;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Util\Tokens;
final class ImmutableDateSniff implements Sniff
{
public function register(): array
{
return [T_NEW];
}
public function process(File $phpcsFile, int $stackPtr): void
{
$tokens = $phpcsFile->getTokens();
$class = $phpcsFile->findNext(Tokens::EMPTY_TOKENS, $stackPtr + 1, null, true);
if ($class === false || ltrim($tokens[$class]['content'], '\\') !== 'DateTime') {
return;
}
$phpcsFile->addError('Use DateTimeImmutable instead of %s', $class, 'Mutable', [$tokens[$class]['content']]);
}
}go deeper
Recall that a sniff is a class with register() listing tokens and process() reporting violations for each matching token.
Explain how the directory layout produces the sniff code, how getTokens() and findNext() are used, and how public properties become ruleset settings.
Show you can add a safe fixer with changesets, test it with .inc and .fixed files, and adapt a 3.x sniff to 4.0's name tokens.
Judge when a house rule deserves a maintained custom sniff versus a configurable built-in sniff or a static-analysis rule.
## When a custom sniff is worth writing Built-in sniffs cover layout and many conventions, and some are configurable enough to express a house rule. For example, `Generic.PHP.ForbiddenFunctions` takes a `forbiddenFunctions` array property. A **custom sniff** is for a rule no configuration can express, such as "instantiate `DateTimeImmutable`, never `DateTime`". It still only looks at **tokens**; it does not know types, so rules that need type inference belong in a static analyser. ## Anatomy of a standard PHP_CodeSniffer finds sniffs by path and class name, and 4.0 **refuses** sniffs that break the convention: ``` Acme/ ruleset.xml Sniffs/ Dates/ ImmutableDateSniff.php -> class Acme\Sniffs\Dates\ImmutableDateSniff ``` - The class name must end in `Sniff`, and it must live in a `Sniffs\<Category>` namespace under the standard's name. - The sniff code is derived from that path: `Acme.Dates.ImmutableDate`. Each message adds a fourth part, such as `Acme.Dates.ImmutableDate.Mutable`. - The class must implement `PHP_CodeSniffer\Sniffs\Sniff`. 4.0 removed support for sniffs that do not. - `ruleset.xml` holds `<ruleset name="Acme">` plus any rules from other standards it builds on. A project uses it by referencing the directory, as in `<rule ref="./tools/Acme"/>`, or by registering the location with `phpcs --config-set installed_paths /path/to/standards` and then using `--standard=Acme`. ## The two methods | Method | Called | Job | |---|---|---| | `register(): array` | once, when the ruleset loads | return the token types to listen for, such as `T_NEW` or `T_FUNCTION` | | `process(File $phpcsFile, int $stackPtr)` | for every matching token in every file | inspect the code around `$stackPtr` and report violations | Inside `process()`: 1. `$tokens = $phpcsFile->getTokens();` gives the whole token array. Each entry has `type`, `code`, `content`, `line`, and for brackets and scopes, pointer keys such as `parenthesis_closer` or `scope_opener`. 2. Navigate with `$phpcsFile->findNext()` / `findPrevious()`, often skipping `Tokens::EMPTY_TOKENS`, which are whitespace and comments. 3. Report with `$phpcsFile->addError($message, $stackPtr, 'Code', $data)` or `addWarning()`. `%s` placeholders in the message are filled from `$data`. 4. Optionally return a stack pointer to skip ahead; returning `$phpcsFile->numTokens` skips the rest of the file. ## Making it fixable `addFixableError()` records the message and returns `true` only when the fixer is active, which means `phpcbf` is running. The fix is then applied through `$phpcsFile->fixer`: - `beginChangeset()` and `endChangeset()` group edits so they apply together; - `replaceToken($ptr, $content)`, `addContent()` and `addNewline()` change the token contents. `phpcbf` re-runs all sniffs after each pass, so a fix must leave code that the sniff no longer flags. Otherwise it loops into a fixer conflict. ## Reporting messages well - **Pick stable error codes.** The fourth part of the code (`Mutable` above) is what users put in `<exclude>` and `phpcs:ignore`. Renaming it later silently breaks their suppressions, which is exactly the pain PHP_CodeSniffer 4.0's own renamed codes caused. - **Use placeholders.** Pass variable parts through `$data` and `%s` in the message rather than concatenating, so the message template stays constant and a ruleset `<message>` override can still use the values. - **Point at the right token.** The `$stackPtr` you report on decides the line and column in the report, and it decides which line a trailing `phpcs:ignore` must sit on. - **Split distinct problems.** Give different problems different codes, so a project can exclude one without losing the others. ## Configurable behaviour Any **public property** on the sniff class can be set from a ruleset with `<property name="..." value="..."/>`, and array properties can be set with `<element>` tags. In 4.0 `true`, `false` and `null` values are cast consistently. ## 4.0 changes that affect sniff authors - Namespaced names are always tokenized with PHP 8's `T_NAME_QUALIFIED`, `T_NAME_FULLY_QUALIFIED` and `T_NAME_RELATIVE`, so a sniff that stitched names together from `T_STRING` and `T_NS_SEPARATOR` must be updated. A sniff matching `new \DateTime` therefore checks for both `T_STRING` and `T_NAME_FULLY_QUALIFIED`. - Method parameters gained types (`int $stackPtr`), and the static token arrays on `Tokens` are deprecated in favour of class constants such as `Tokens::EMPTY_TOKENS`. - The test base class is now `PHP_CodeSniffer\Tests\Standards\AbstractSniffTestCase`. It runs the sniff over a `.inc` fixture, compares the expected error lines, and requires a `.fixed` file whenever the fixer changes the fixture.
- Why does a sniff written for 3.x miss `new \App\Clock\SystemClock` in 4.0?In 4.0, PHP_CodeSniffer tokenizes namespaced names with PHP 8's name tokens on every PHP version, so `\App\Clock\SystemClock` is one `T_NAME_FULLY_QUALIFIED` token, not a chain of `T_NS_SEPARATOR` and `T_STRING`. A sniff that only looks for `T_STRING` after `new` never matches it.
- How do you make the sniff's behaviour configurable per project?Declare a public property on the sniff class, for example `public $forbiddenClasses = ['DateTime'];`, and read it in `process()`. A project then sets it in its ruleset under the sniff's `<rule ref>` with `<property name="forbiddenClasses" type="array">` and `<element value="..."/>` tags, adding `extend="true"` to keep the default entries.
- How is a custom sniff tested?Extend `PHP_CodeSniffer\Tests\Standards\AbstractSniffTestCase`, put sample code in a `.inc` file next to the test, and return line-number-to-count maps from `getErrorList()` and `getWarningList()`. If the sniff is fixable, a matching `.fixed` file holding the expected output is required in 4.0.
saying these in an interview costs you the question
- A sniff can use type information to know a variable's class
- Any class with a process() method is picked up as a sniff
- process() runs once per file rather than once per matching token
- addFixableError() changes the code by itself
- Namespaced names are still split into T_STRING and T_NS_SEPARATOR in 4.0