Adding a Lint Rule
This page is for contributors to the library. Lint rules are not an extension point: the rule contract and its registry are internal, and a project that uses PHPRegex cannot add a rule of its own (see Backward Compatibility). To check patterns for your own purpose, write a visitor, as the extending guide shows.
The steps below follow what one rule touched when it was added:
regex.lint.lookaround.redundant, the rule that reports (?=a(?=b)), which
asserts what (?=ab) asserts. One commit added its class, registered it,
gave it an entry in the rule catalogue, a reference entry and its tests.
The linter’s internal classes may move between releases. The steps stay the same, and each step names the test that fails until it is done: when a class named here has moved, that test points to where the list lives now.
Step 0: Decide What the Rule Reports
- The id. A rule id reads
regex.lint.<area>.<name>, asregex.lint.lookaround.redundant. The id is public once released: it names the finding in the JSON report ofregex lint, it is the PHPStan error identifier users ignore errors by, and the LSP diagnostic code. Pick it with care. - The configuration key. The key in
regex.jsonis the id withoutregex.lint.:"lookaround.redundant": trueunderchecks.lint.rules. - On or off by default. A rule that finds a defect runs by default. A
rule about style or speed (
LintSeverity::StyleorLintSeverity::Perf) is off by default, and a project turns it on. - The engine decides. A rule that says two spellings match the same thing
must be checked against PCRE: run both through
preg_match_all()on a list of subjects before you write the rule, as in Step 6.
Step 1: Write the Rule Class
Location: src/Linter/Rule/ZeroQuantifierRule.php
A rule is a final class in src/Linter/Rule/ that extends
AbstractLintRule and says @internal. The smallest rules look like this
one, as shipped:
<?php
declare(strict_types=1);
namespace PHPRegex\Linter\Rule;
use PHPRegex\Linter\Rule\Support\QuantifierMath;
use PHPRegex\Parser\Node\NodeInterface;
use PHPRegex\Parser\Node\QuantifierNode;
/**
* Detects {0} quantifiers, which always repeat zero times.
*
* @internal
*/
final class ZeroQuantifierRule extends AbstractLintRule
{
public const ID = 'regex.lint.quantifier.zero';
public function getRuleIds(): array
{
return [self::ID];
}
public function getNodeTypes(): array
{
return [QuantifierNode::class];
}
public function check(NodeInterface $node, LintContext $context): array
{
if (!$node instanceof QuantifierNode) {
return [];
}
if (!str_starts_with($node->quantifier, '{')) {
return [];
}
[$min, $max] = QuantifierMath::parseRange($node->quantifier);
if (0 !== $min || 0 !== $max) {
return [];
}
return [new RuleViolation(
self::ID,
'Quantifier always repeats zero times; it can be removed.',
$node->startPosition,
'Remove the quantified element or replace it with an empty pattern.',
end: $node->getEndPosition(),
)];
}
}
IDis the id, as a public constant: the rule reports it throughself::ID, and the rule catalogue names the rule by it. A class that reports a few ids whose findings belong together gives each one a constant of its own,ID_and a name, asImpossibleAnchorRule::ID_START.RuleCatalogTestfails when an id a rule reports is not one of its constants.getRuleIds()lists every id the rule reports.getNodeTypes()lists the node classes the rule inspects. The linter walks the tree once and callscheck()on each node of those classes.check()returns a list ofRuleViolation: the id, the message, the byte offset where the finding starts in the pattern body, a hint, the severity (LintSeverity::Warningwhen none is given) andend:, where the finding ends.begin()resets what a rule keeps between nodes, andfinish()reports what the rule could only know once it has seen the whole pattern.AbstractLintRulegives both an empty body.
RedundantLookaroundRule.php shows the parts a newer rule adds:
LintSeverity::Style for a style rule, a message that
quotes the pattern and a hint that names the rewrite:
Write "(?=ab)".
Step 2: Register It
Add the rule at the end of the constructor of LintRuleRegistry
(src/Linter/Rule/LintRuleRegistry.php):
$this->register(new RedundantLookaroundRule());
The rules run in the order they are registered, and their findings come out in that order, so a new rule goes last.
LintRuleRegistryTest fails until it lists the new id in
test_default_registry_order_is_stable() and the new count in
test_all_N_rule_ids_are_covered(), whose name carries the count.
Step 3: Add It to the Rule Catalogue
RuleCatalog (src/Linter/Rule/RuleCatalog.php) holds, for each rule, what
it reports, whether it runs by default and the section of the docs that
explains it. Add the rule to LINT_RULES, last:
RedundantLookaroundRule::ID => ['Report a lookaround at the edge of a lookaround read the same way, which can hold its body (style).', false, 'https://php-regex.com/reference/rules/#lookaround-inside-a-lookaround'],
Everything else reads the catalogue:
- the
checks.lint.ruleskey ofregex.json, the id withoutregex.lint.("lookaround.redundant": true; the id in full is accepted too), with its description and its default, and the list of the rules off by default in the description ofchecks.lint.rules; --enable-ruleand--disable-rule, which refuse an id the catalogue does not hold, and the inline markers, which read the short id;- the link of each finding:
docs_urlin the JSON report, the last line of the GitHub and Checkstyle messages, thecodeDescriptionof the editor’s diagnostic and the “Read more” tip of the PHPStan error. The PHPStan rule reports each finding with the rule id as its error identifier.
Then:
- Write
src/Linter/regex.schema.jsonagain from the definition:php tests/Tools/write_config_schema.php. - For a rule off by default, add its key to the list in the
checks.lint.rules.<id>row ofsrc/Linter/README.md.
The tests that fail until this is done:
RuleCatalogTest: the catalogue lists exactly the rules the registry runs, and each links to the docs site;RuleDocsAnchorsTest: the link lands on a heading of the docs whose section names the id (write the section of Step 5 first);LintRuleConfigurationTest: the schema file lists the rule, andprovideNewRuleDefaults()holds a row for the new key;LintRuleInventoryTest: the Linter README names every rule off by default;LintConfigSchemaTest::test_schema_file_is_written_from_the_php_definition.
Step 4: Check the Editor and PHPStan Output
Nothing to add: the language server and the PHPStan rule read the id and
the link from the catalogue. Run the PHPStan tests of the rule’s area, whose
expected tips end with the new Read more: line.
Step 5: Document It
docs/reference/rules.md: a section under the rule’s area, with its heading (the one the catalogue links to), then Identifier: (with “off by default” when it is), When it triggers:, Message:, Example: and Fix:, and a row in the Quick Reference Table.SonarParityReferenceTestchecks the rows of the rules mapped to a SonarPHP rule.docs/guides/lsp.md: a row in the table of diagnostic codes, with the LSP severity itsLintSeveritymaps to.docs/reference/diagnostics-cheatsheet.md: a quick fix, when the finding is one people will often meet.- The rule count:
src/Linter/README.md,src/Cli/README.mdandsrc/PHPStan/README.mdeach give the number of lint rules.LintRuleInventoryTest::test_a_page_counts_the_rules_the_registry_holdsfails until each says the new number. - The changelogs: one line in
CHANGELOG.md, and an entry insrc/Linter/CHANGELOG.md. For a rule off by default, the entry says so, and the list “The rules off by default are …” names it:LintRuleInventoryTest::test_the_linter_changelog_names_each_rule_off_by_default.
Step 6: Write the Tests, Engine First
Write the test before the rule. The test suite’s conventions: a final class,
each test marked #[Test] and named test_{feature}_{scenario}, a static
data provider that returns iterable and yields keyed rows, and the engine
asked first, in the test itself.
Location: tests/Unit/Lint/Rule/RedundantLookaroundTest.php
The rule says the pattern and its rewrite match the same thing. Each reported row checks that claim against PCRE before it checks the rule, and each silent row checks that the rewrite would change the matches:
<?php
declare(strict_types=1);
namespace PHPRegex\Tests\Unit\Lint\Rule;
use PHPRegex\Linter\LintSeverity;
use PHPRegex\Linter\PatternLinter;
use PHPRegex\Linter\Rule\RuleViolation;
use PHPRegex\Toolkit\Regex;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RedundantLookaroundTest extends TestCase
{
private const RULE = 'regex.lint.lookaround.redundant';
private const SUBJECTS = ['', 'a', 'b', 'ab', 'abc', 'ba', 'aab', 'xab'];
#[Test]
#[DataProvider('provideInlinableLookarounds')]
public function test_lookaround_its_parent_can_hold_is_reported(string $pattern, string $rewrite, string $hint): void
{
// The engine first: the rewrite the tip names finds the same matches.
foreach (self::SUBJECTS as $subject) {
$this->assertSame(
[preg_match_all($rewrite, $subject, $expected, \PREG_OFFSET_CAPTURE), $expected],
[preg_match_all($pattern, $subject, $actual, \PREG_OFFSET_CAPTURE), $actual],
$pattern.' on '.$subject,
);
}
$issues = self::issues($pattern, true);
$this->assertCount(1, $issues);
$this->assertSame($hint, $issues[0]->hint);
$this->assertSame(LintSeverity::Style, $issues[0]->severity);
}
/**
* @return iterable<string, array{pattern: string, rewrite: string, hint: string}>
*/
public static function provideInlinableLookarounds(): iterable
{
yield 'a lookahead ending a lookahead' => ['pattern' => '/(?=a(?=b))/', 'rewrite' => '/(?=ab)/', 'hint' => 'Write "(?=ab)".'];
yield 'a lookbehind starting a lookbehind' => ['pattern' => '/(?<=(?<=a)b)/', 'rewrite' => '/(?<=ab)/', 'hint' => 'Write "(?<=ab)".'];
}
#[Test]
#[DataProvider('provideLookaroundsThatMatter')]
public function test_lookaround_that_changes_the_assertion_is_not_reported(string $pattern, string $rewrite): void
{
// The engine first: the rewrite would find other matches.
$differs = false;
foreach (self::SUBJECTS as $subject) {
$differs = $differs
|| [preg_match_all($pattern, $subject, $actual, \PREG_OFFSET_CAPTURE), $actual]
!== [preg_match_all($rewrite, $subject, $expected, \PREG_OFFSET_CAPTURE), $expected];
}
$this->assertTrue($differs, $pattern.' behaves as '.$rewrite);
$this->assertSame([], self::issues($pattern, true));
}
/**
* @return iterable<string, array{pattern: string, rewrite: string}>
*/
public static function provideLookaroundsThatMatter(): iterable
{
yield 'a negative lookahead inside' => ['pattern' => '/(?=a(?!b))/', 'rewrite' => '/(?=a[^b])/'];
yield 'a lookahead starting a lookahead' => ['pattern' => '/(?=(?=b)a)/', 'rewrite' => '/(?=ba)/'];
}
#[Test]
public function test_lookaround_rule_is_off_by_default(): void
{
$this->assertSame([], self::issues('/(?=a(?=b))/', false));
}
/**
* @return list<RuleViolation>
*/
private static function issues(string $pattern, bool $enabled): array
{
$linter = new PatternLinter($enabled ? ['lookaround.redundant' => true] : []);
Regex::create(['cache' => null])->parse($pattern)->accept($linter);
return array_values(array_filter(
$linter->getIssues(),
static fn (RuleViolation $issue): bool => self::RULE === $issue->id,
));
}
}
The shipped test holds more rows: each place the rule must stay silent, each case its message covers, and a subject list long enough to tell the rewrite from the pattern.
LintRuleExamplesTest also wants, for each rule id, one pattern the rule
reports and one sound pattern it leaves alone, in its EXAMPLES list; a rule
off by default goes in its ENABLE_ALL list too. Its
test_every_registered_rule_has_an_example() fails until then.
Checklist
What regex.lint.lookaround.redundant touched, with what fails when a place
is missed:
| Where | What | What fails when it is missed |
|---|---|---|
src/Linter/Rule/RedundantLookaroundRule.php |
the rule and its ID |
the rule’s own test, RuleCatalogTest |
src/Linter/Rule/LintRuleRegistry.php |
the registration | LintRuleRegistryTest, LintRuleExamplesTest |
src/Linter/Rule/RuleCatalog.php |
the description, the default, the docs link | RuleCatalogTest, LintRuleConfigurationTest |
src/Linter/regex.schema.json |
written from the definition | LintConfigSchemaTest |
docs/reference/rules.md |
the section and the Quick Reference row | RuleDocsAnchorsTest for the heading the link names |
docs/guides/lsp.md |
the row of diagnostic codes | nothing: check it by hand |
src/Linter/README.md, src/Cli/README.md, src/PHPStan/README.md |
the rule count, and the list of rules off by default | LintRuleInventoryTest |
CHANGELOG.md, src/Linter/CHANGELOG.md |
the entries | LintRuleInventoryTest, for a rule off by default |
tests/Unit/Lint/Rule/RedundantLookaroundTest.php |
the rule’s test, engine first | |
tests/Unit/Lint/Rule/LintRuleExamplesTest.php, LintRuleRegistryTest.php, LintRuleConfigurationTest.php |
the rows for the new id | each fails until its row is there |
Run the linter’s tests and the documentation’s while you work:
composer phpunit -- tests/Unit/Lint tests/Integration/Bridge/PHPStan tests/Documentation