Pravidlo do detailu

Co musí každé pravidlo dodržet: opravovat až po ohlášení, být bez stavu a idempotentní, správně zvolit fázi, vyplnit atribut RuleInfo, popsat své volby schématem a hlásit i problémy v bílých znacích.

Dvě podoby pravidla

Základem je třída DressCode\Rule a existují dvě podoby, mezi kterými se vybírá podle toho, co pravidlo dělá:

  • NodeRule navštěvuje uzly a tokeny, které si vyžádá, a pracuje s nimi v metodách enter() a leave(). Takhle je psaná většina pravidel a je o nich celá tahle stránka.
  • GapRule nenavštěvuje nic. Jen vysloví požadavek na to, co má být v bílých znacích mezi tokeny, a vyhodnotí to za něj jádro nástroje. Píše se jinak a má vlastní stránku.

Třída je vždy jen jedno, nebo druhé. Pravidlo, které by potřebovalo obojí, jsou ve skutečnosti dvě pravidla se dvěma jmény, aby šlo každé vypnout zvlášť a aby bylo z hlášení poznat, které z nich mluví.

Tvar pravidla

Pravidlo dědí od DressCode\NodeRule a nese atribut #[RuleInfo]:

#[RuleInfo(
	'acme/no-var-dump',
	Stage::Structure,
	description: 'Reports calls of var_dump()',
	group: null,
	requires: [],
	modifiesComments: false,
	risky: false,
)]
final class NoVarDumpRule extends NodeRule
{
	// ...
}
  • Jméno vendor/slug je identita pravidla: objevuje se ve výpisu, v konfiguraci, v komentářích dresscode:ignore i v baseline. Dvě třídy se stejným jménem jsou chyba konfigurace.
  • Fáze (Stage) říká, ve které ze tří fází průchodu pravidlo běží. Structure je pro změny kódu (přepis výrazu, odstranění importu), Formatting pro bílé znaky a zalomení řádků, Cleanup pro závěrečný úklid (mezery na konci řádků, konec souboru, délka řádku). Průchody jdou v tomhle pořadí, takže formátování už vidí kód po strukturních změnách.
  • Popis je jedna anglická věta v oznamovacím způsobu o tom, co pravidlo dělá; vypisuje ji dresscode rules.
  • group je skupina, ke které pravidlo patří, případ výčtu Group. Uveďte ji tehdy, když pravidlo plní některý z těch šesti záměrů; pravidlo se pak zapne každému, kdo si o skupinu řekl, aniž by o něm věděl. Nechte null u pravidla, které je rozhodnutím o vzhledu kódu nebo politikou se seznamem od projektu: takové vybírá standard, nebo ho uživatel zapne jménem.
  • requires říká, co pravidlo potřebuje od projektu, a píše se jako require v composer.json: ['php' => '>=8.1'] u pravidla, které zapisuje syntaxi až od nějaké verze PHP (třeba 0o755 od PHP 8.1), ['nette/forms' => '>=3.3'] u pravidla pro API knihovny, ['nette/forms' => '*'], když stačí, že knihovna v projektu je. Kde to pravidlo nemá, se samo vynechá a preset ani skupina to nemusí hlídat. PHP 8.0 je nejnižší podporovaná verze, takže na nic, co v 8.0 už bylo, se ptát nemusíte.
  • modifiesComments nastavte na true jen tehdy, když pravidlo opravdu mění text komentářů. Jinak RuleTester hlídá, že žádný komentář nezmizel ani se nezměnil, což je nejčastější chyba oprav.
  • risky nastavte na true, když může změnit chování kódu každá oprava pravidla, protože bezpečný případ z kódu poznat nejde: dresscode/strict-call neví, jestli kód na volném porovnání v in_array() nespoléhá. Každé hlášení takového pravidla je pak rizikové, viz níže.

Které uzly a kdy

public function getVisitedTypes(): array
{
	return [FunctionCallNode::class];
}

Je to seznam tříd uzlů (nebo Token::class), pro které jádro zavolá enter() a leave(). Porovnává se přes instanceof, takže StatementNode::class zachytí každý příkaz a Node::class úplně všechno; čím užší seznam, tím rychlejší běh. Prázdný seznam znamená, že pravidlo pracuje jen v beforeFile() a afterFile(), což dělají pravidla nad celým souborem, například to o délce řádku.

enter() se volá při vstupu do uzlu, tedy před jeho dětmi, leave() až po nich. Když pravidlo uzel v enter() nahradí nebo odstraní, jádro do něj už nesestoupí a leave() pro něj nezavolá.

Nejdřív ohlásit, teprve pak opravit

Tohle je nejdůležitější pravidlo celého kontraktu: strom se smí změnit až poté, co report() vrátil true.

if ($context->report($node, 'The var_dump() call must not stay in the code')) {
	$node->remove();
}

report() vrátí false, pokud je porušení na svém řádku potlačené komentářem, nebo pokud je oprava riziková a běh ji nedovolil; pak se nesmí nic měnit. Porušení, které zná baseline, report() nezamítne: nehlásí se, ale pravidlo ho opravit smí. Nespoléhá se přitom na dobrou vůli: strom počítá své změny a jádro každou změnu spáruje s hlášením ze stejného volání. Změna bez hlášení, nebo po hlášení, které vrátilo false, je porušený kontrakt. Za běhu je z toho varování, s přepínačem --strict-rules a v RuleTesteru chyba.

Oprava, která může změnit chování kódu jen v některém výskytu, se hlásí s risky: true právě u toho výskytu:

if ($context->report($node, 'The class name must be obtained with $x::class', risky: true)) {
	// ...
}

Takové hlášení vrátí false, dokud projekt pravidlo neuvede v klíči fixRisky nebo běh nedostane --fix-risky; pravidlo pak kód nechá a porušení s příznakem rizika zůstane mezi tím, co fix neopravil. Totéž pravidlo v témž průchodu opraví bezpečný výskyt a rizikový jen ohlásí. Pravidlo, jehož riziková je každá oprava, nepíše risky: true ke každému hlášení, ale jednou do RuleInfo. Fixtura s // risky v hlavičce ukazuje, jak pravidlo opravuje, když smí, a je zároveň důkaz pro referenci, že pravidlo takové opravy má.

Výskyt, pro který pravidlo opravu nemá a jen ho hlásí, ohlaste s fixable: false. report() pak vrátí false vždy a hlášení nikdy není rizikové. Jinak by rizikové hlášení bez opravy vypadalo jako oprava, která čeká na svolení, a souhrn by radil --fix-risky, přestože by se nic neopravilo. Třeba dresscode/strict-call explicitní false v in_array() jen hlásí, protože ho autor kódu napsal vědomě:

$context->report($strict, 'The in_array() call must pass $strict = true', fixable: false);

Na zapomenutou deklaraci upozorní RuleTester: fixtura s // risky, po jejímž běhu zůstane rizikové porušení, selže, protože oprava byla povolená a přesto se neudělala.

Hlášení se váže na uzel nebo na token. Problém, který leží v bílých znacích nebo v komentáři, ohlaste s příslušnou trivia (report($token, $message, trivia: $trivia)), aby porušení dostalo řádek té trivia a dresscode:ignore na tom řádku ho našel; jinak spadne na řádek tokenu. Závažnost je Severity::Error; Severity::Warning je pro nález, který nemá shodit build, a stejný účinek dá uživateli klíč warnings v konfiguraci, který změkčí kterékoli pravidlo.

Zpráva popisuje kód, ne čtenáře, a nikdy nerozkazuje. Má jeden ze tří tvarů: požadovaný stav (A single space after the comma), norma (The opening brace must be on its own line), nebo nález (Function foo() is deprecated). Konkrétní jména a hodnoty do zprávy patří, jméno pravidla ne, to doplní výpis.

Bez stavu a idempotentní

Jedna instance pravidla slouží celému běhu a všem souborům. Stav vztažený k jednomu souboru patří do pole $context->storage, které jádro pro každý soubor založí prázdné; vlastnosti třídy jsou jen na volby.

Pravidla se pouštějí opakovaně, dokud se strom mění, takže pravidlo musí být idempotentní: nad vlastním výstupem už nesmí nic ohlásit ani změnit. A nesmí záviset na pořadí průchodu ani na tom, kolikátý průchod zrovna běží; kontext to schválně neprozradí. Dvě pravidla, která se přetahují, jádro pozná a soubor ohlásí jako selhání se jmény obou. Idempotenci ověřuje i RuleTester: pustí pravidlo nad jeho vlastním výstupem podruhé a čeká ticho.

Co strom dovolí

Slot uzlu se zapisuje přiřazením ($if->condition = $expression), uzel se nahrazuje metodou replaceWith() a odstraňuje metodou remove(), text a trivia tokenu mění jeho vlastní metody. O rodiče a o index tokenů se strom stará sám, a kde na to nemá property hook, hlídá zápis viditelnost vlastnosti; strom tedy nerozbijete ani zápisem mimo API. Podrobnosti jsou na stránce Úpravy; pro pravidla k tomu platí navíc:

  • Sourozence měňte z callbacku jejich vlastníka (FileNode, BlockNode, ClassNode) nebo z afterFile(), ne z enter() položky, kterou právě procházíte. Jádro procházený seznam nepřepočítává.
  • Novou konstrukci nestavějte z tokenů, ale naparsujte ji: Parser::parseExpression(), parseStatement(), parseType(), parseName().
  • Komentář nesmí zmizet, dokud pravidlo neřekne modifiesComments. Před zásahem se ptejte Token::hasComment(), Token::hasCommentUpTo() nebo Node::hasComment(); odstraňujte komentář jen přes removeTrivia().
  • Konec řádku, který uzavírá řádek tokenu, patří do jeho koncových trivia, ne do úvodních trivia dalšího tokenu. Metody ensureLeadingNewline() a setBlankLinesBefore() to dělají správně, tak na nich stavějte.
  • Na otázky „je tenhle výraz stejný jako tamten“ a „dá se bezpečně vyhodnotit dvakrát“ odpovídají Node::matches() a Node::isRepeatableRead(). Neimplementujte je znovu.

Volby pravidla

Pravidlo s volbami implementuje rozhraní ConfigurableRule: dodá schéma z knihovny nette/schema a metodu configure(), která dostane volby už zvalidované:

final class ForbiddenFunctionsRule extends NodeRule implements ConfigurableRule
{
	/** @var list<string> */
	private array $functions = [];


	public static function getOptionsSchema(): Schema
	{
		return Expect::structure([
			'functions' => Expect::listOf('string')->default(['var_dump', 'print_r'])
				->description('Names of the forbidden functions'),
		]);
	}


	public function configure(array $options): void
	{
		$this->functions = $options['functions'];
	}
}

Jména voleb se píší v camelCase. Mapa voleb se slévá s vrstvou pod ní po klíčích, seznam zadaný v konfiguraci nahrazuje výchozí hodnotu celou; s tím počítejte v popisu volby. Popis vůbec uvádějte jen tam, kde jméno, typ a výchozí hodnota neříkají všechno.

Když pravidlo s danými volbami nic nedělá, třeba proto, že seznam zakázaných funkcí zůstal prázdný, může na to upozornit samo schéma. V transform() zavolá $context->addWarning():

return Expect::structure([
	'functions' => Expect::listOf('string'),
])->transform(function (mixed $options, Context $context): mixed {
	if (((array) $options)['functions'] === []) {
		$context->addWarning('No function is given, so nothing is reported.', 'acme.noEffect');
	}

	return $options;
});

DressCode zprávu vypíše mezi varováními konfigurace jako Rule acme/forbidden-functions: No function is given, so nothing is reported., a to jen jednou, i když se konfigurace řeší pro víc přepisů. Zprávu pište jako celou větu. Stejně se totiž vypíše i varování, které vydá nette/schema samo, třeba u volby označené deprecated().

Volba, která pojmenovává rozhodnutí (tvar, polohu, zarovnání), přijímá i keep: pravidlo pak tohle rozhodnutí nevynucuje a zbytek hlídá dál. Volba, která rozhodnutí není (ignoreImports, seznam vzorů), keep nemá. Pravidlo, které je jedním jediným rozhodnutím, řekne v RuleInfo parametrem decision, kterou volbu vyplní hodnota zapsaná přímo u pravidla, takže uživatel píše yoda: forbidden místo mapy s jedním klíčem.

Pravidlo, u kterého verze PHP rozhoduje o tom, co smí zapsat (ne o tom, jestli vůbec poběží), se zeptá $context->getPhpVersion(). Vrací řetězec ve tvaru major.minor, který se porovnává funkcí, ne operátory, protože '8.10' > '8.9' jako řetězec neplatí:

if (version_compare($context->getPhpVersion(), '8.4', '>=')) {
	// ...
}

Analýzy

Informace o souboru, kterou potřebuje víc pravidel, patří do analýzy. Je to obyčejná třída, jejíž konstruktor přijme FileNode (nebo nic). Pravidlo si ji vyžádá:

$resolver = $context->getAnalysis(NameResolver::class);
if ($resolver->isGlobalFunctionCall($node, 'var_dump')) {
	// ...
}

Jádro analýzu vytvoří napoprvé a drží ji, dokud se strom nezmění; po každé změně vzniká znovu, takže nikdy nečtete zastaralý stav. Vestavěné jsou PhpSyntax\Analyses\NameResolver (jmenný prostor, importy, překlad jmen), PhpSyntax\Analyses\Scope (funkce, třída, dostupnost $this) a DressCode\Analyses\PhpDoc (dokumentační komentáře jako strom z parseru phpDocu). Vlastní analýza s konstruktorem nad FileNode se nikde neregistruje; jen ta, která potřebuje továrnu, se zapisuje do konfigurace klíčem analyses.

Pravidlo, které do souboru zapisuje kód, o jehož tvaru rozhoduje jiné pravidlo, se může zeptat přímo toho pravidla. $context->findRule(ImportNotationRule::class) vrátí jeho instanci tak, jak je pro soubor nakonfigurovaná, nebo null, když na soubor neběží. Tak name-notation přidá import funkce do existujícího use function, když import-notation chce importy spojené, a jinak mu dá vlastní příkaz.

NameResolver ví i to, co v jednom souboru vidět nejde: které funkce a konstanty projekt deklaruje ve jmenných prostorech, jak to říkají klíče namespaces a nameResolution. isGlobalFunctionCall() s tím počítá sám. Když projekt neřekl nameResolution: certain, stojí odpověď jen na předpokladu, že nekvalifikované jméno je globální, a to prozradí getUnqualifiedResolution() odpovědí UnqualifiedResolution::Uncertain. Ptá se jen na nekvalifikované jméno, protože jméno s lomítkem do globálního prostoru nepadá. Pravidlo, jehož oprava na tom předpokladu stojí, ji u takového výskytu ohlásí jako rizikovou:

if ($resolver->isGlobalFunctionCall($node, 'is_null')) {
	$uncertain = $node->name->isUnqualified()
		&& $resolver->getUnqualifiedResolution($node->name->text, SymbolKind::Function, $node) === UnqualifiedResolution::Uncertain;
	if ($context->report($node, 'The is_null() call must be a comparison with null', risky: $uncertain)) {
		// ...
	}
}

Jméno funkce, které pravidlo do kódu nově zapisuje a které žádné původní jméno nenahrazuje, neskládejte z řetězce, ale vezměte z $resolver->getShortName('count', SymbolKind::Function, $node): podle importů a konfigurace řekne, jestli se má napsat count(), nebo \count(). Když jméno nahrazuje jiné, jako sizeof() na count(), napište ho stejně jako to původní, s lomítkem nebo bez něj. Holé jméno ale jen tehdy, když ho soubor neimportuje ani nedeklaruje, což řekne isAliasFree('count', SymbolKind::Function, $node), a když není méně jisté než to původní: getUnqualifiedResolution() pro nové jméno odpoví Global, nebo pro původní Uncertain. Jinak patří před jméno lomítko. Proč na tom záleží, popisuje stránka Funkce a konstanty ve jmenných prostorech.

Testování

Každé pravidlo má fixtury a RuleTester, který na nich ověří výstup, hlášení a všechno výše popsané: Testování pravidel.