Jak napsat vlastní pravidlo

Od fixtury k hotovému pravidlu za odpoledne: napíšete kód před opravou a po ní, řeknete, které uzly vás zajímají, sepíšete podmínky a RuleTester pohlídá zbytek.

Co budeme psát

Každý tým má pár pravidel, která žádný standard nepokrývá, protože jsou jenom jeho. Vezměme si tohle: zpráva výjimky je věta a končí tečkou, takže throw new InvalidArgumentException('The name must not be empty') chceme vidět jako 'The name must not be empty.'. Roky jsem to hlídal v code review, protože napsat na to sniff (pravidlo pro PHP_CodeSniffer) dalo víc práce, než kolik to ušetřilo. Tady to bude čtyřicet řádků a napíšeme si je spolu.

Nejdřív fixtura

Pravidlo se testuje nad fixturami (fixtures), tedy dvojicemi souborů s kódem před opravou a po ní. Jedna fixtura je až trojice souborů: .code s kódem před opravou, .expected s kódem po ní a .violations s řádky a zprávami, které má pravidlo ohlásit. Fixtura je nejlepší zadání, jaké si můžete napsat, tak s ní začneme. Do tests/fixtures/exception-message-period/basic.code:

<?php

throw new InvalidArgumentException('The name must not be empty');
throw new \RuntimeException('Cannot connect to the database.');
throw new App\NotFoundException("Order $id not found");
throw new LogicException('Unreachable ');
$logger->log('Started');

Už tady se rozhoduje, co pravidlo umí a co ne: druhý řádek je v pořádku, třetí obsahuje interpolovaný řetězec a ten necháme být (kdo ví, co je v $id), čtvrtý má na konci mezeru navíc, kterou tečka spolkne, a pátý není výjimka. Soubor basic.expected je totéž s tečkami na řádcích 3 a 6 a basic.violations říká, co se má ohlásit:

3: The message of an exception must end with a period
6: The message of an exception must end with a period

Kostra pravidla

Pravidlo je třída odvozená od DressCode\NodeRule s atributem #[RuleInfo], který nese jméno, fázi a popis. Jméno má tvar vendor/slug a je to to, co uvidíte ve výpisu a co budete psát do dresscode:ignore. Fáze (Stage) říká, kdy pravidlo běží: Structure pro změny kódu, Formatting pro bílé znaky a zalomení, Cleanup pro závěrečný úklid. Naše pravidlo mění text, takže patří do Structure.

namespace App\CodeStyle;

use DressCode\NodeRule;
use DressCode\RuleContext;
use DressCode\RuleInfo;
use DressCode\Stage;
use PhpSyntax\Node;
use PhpSyntax\Nodes\Expression\NewNode;
use PhpSyntax\Token;

#[RuleInfo('app/exception-message-period', Stage::Structure, description: 'Ends the message of an exception with a period')]
final class ExceptionMessagePeriodRule extends NodeRule
{
	public function getVisitedTypes(): array
	{
		return [NewNode::class];
	}


	public function enter(Node|Token $node, RuleContext $context): void
	{
	}
}

NodeRule je jedna ze dvou podob pravidla: navštěvuje uzly stromu a pracuje na nich. Druhá podoba je GapRule, které místo návštěv vyslovuje požadavky na bílé znaky.

Metoda getVisitedTypes() říká, které uzly chcete vidět; jádro pak volá enter() jen pro ně. Nás zajímá new, tedy NewNode. Které uzly existují a co v nich najdete, říká přehled uzlů: NewNode má sloty newKeyword, class a args.

Podmínky a oprava

Teď to podstatné. Do enter() napíšeme jako řadu otázek na strom, kdy je co špatně (k importům přibudou PhpSyntax\Nodes\NameNode a PhpSyntax\Nodes\Scalar\StringNode):

public function enter(Node|Token $node, RuleContext $context): void
{
	if (
		!$node instanceof NewNode
		|| !$node->class instanceof NameNode
		|| !str_ends_with($node->class->text, 'Exception')
	) {
		return;
	}

	$message = $node->args?->items->getItems()[0]->expr ?? null;
	if (
		!$message instanceof StringNode
		|| $message->quote !== "'"
		|| $message->value === ''
		|| str_ends_with(rtrim($message->value), '.')
		|| !$context->report($message, 'The message of an exception must end with a period')
	) {
		return;
	}

	$message->setValue(rtrim($message->value) . '.');
}

Čtěte to shora: je to new se jménem třídy (tedy ne new $class ani anonymní třída) a jméno končí na Exception. První argument je řetězec bez interpolace, protože interpolovaný řetězec je jiný uzel než StringNode, takže třetí řádek fixtury odpadne sám. Ten řetězec je v jednoduchých uvozovkách, není prázdný a nekončí tečkou. A poslední otázka: nikdo pravidlo v tomhle místě nepotlačil. Volání report() vrátí false, pokud na řádku stojí dresscode:ignore, a pak se nesmí nic měnit. Proto stojí v podmínce, a ne před ní.

Teprve za tím vším je oprava: jediné setValue(). Nezapisujeme text tokenu, ale hodnotu řetězce, takže o escapování a uvozovky se postará uzel sám. Zbytek souboru se nezmění ani o bajt.

Všimněte si, co v pravidle není: žádné počítání závorek, žádné hledání, kde končí argument, žádná obrana proti komentáři uprostřed volání. To všechno ví strom. Nad plochým polem tokenů by většina kódu řešila, co všechno může stát mezi new a řetězcem; tady taková otázka vůbec nevzniká.

Test pravidla

RuleTester dostane třídu pravidla a adresář s fixturami a ověří všechno, na co byste sami zapomněli: že výstup odpovídá souboru .expected, že hlášení odpovídají souboru .violations, že pravidlo nad vlastním výstupem už nic nemění, že nezahodilo žádný komentář, že poslechne dresscode:ignore-file a že nic nezměnilo bez ohlášení. Z Nette Testeru vypadá takový test takhle:

use App\CodeStyle\ExceptionMessagePeriodRule;
use DressCode\Testing\RuleTester;

require __DIR__ . '/../vendor/autoload.php';
Tester\Environment::setup();

RuleTester::run(ExceptionMessagePeriodRule::class, __DIR__ . '/fixtures/exception-message-period');

Z PHPUnit je to totéž volání uvnitř testovací metody. Selhání je výjimka DressCode\Testing\TestFailure s diffem, takže ji každý framework ukáže srozumitelně.

Když teď test pustíte, projde. Kdyby neprošel, řekne vám diff, na kterém řádku fixtury pravidlo vidí něco jiného než vy, a to bývá chvíle, kdy se o kódu něco nového dozvíte. V tomhle případě to bylo rtrim(): v první verzi pravidla nebylo a fixtura s mezerou na konci ho vynutila.

Zapnutí v projektu

Třídu můžete dát kamkoli, kam vede autoload; vlastní pravidla mívám v adresáři tools/CodeStyle/ s autoload-dev, aby se DressCode\NodeRule nedostal do produkčního autoloadu. V konfiguraci se pravidlo zapíná názvem třídy a jméno z atributu se zaregistruje samo:

rules:
	App\CodeStyle\ExceptionMessagePeriodRule: true

Od téhle chvíle ho dresscode check hlásí, dresscode fix opravuje, komentář // dresscode:ignore app/exception-message-period potlačuje a v editoru se ukazuje jako každé jiné. Když stejné pravidlo potřebuje víc projektů, zabalte ho do presetu nebo rozšíření.

Kam dál

  • Pravidlo, které má mít volby (třeba seznam přípon výjimek), implementuje rozhraní ConfigurableRule se schématem. Jak na to a co všechno musí pravidlo dodržet, je na stránce Pravidlo do detailu.
  • Naše pravidlo poznává výjimku podle jména. Kdo chce víc, sáhne po analýze NameResolver, která jméno přeloží podle importů a jmenného prostoru; analýzy popisuje PhpSyntax.
  • Pravidlo pro mezery a zalomení řádků se píše jinak, požadavkem místo enter(): Pravidla pro bílé znaky.
  • Kdo přepisuje existující sniff nebo fixer, má vlastní návod: Přepis pravidla z jiného nástroje.
verze: 1.0