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á:
NodeRulenavštěvuje uzly a tokeny, které si vyžádá, a pracuje s nimi v metodáchenter()aleave(). Takhle je psaná většina pravidel a je o nich celá tahle stránka.GapRulenenavš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/slugje identita pravidla: objevuje se ve výpisu, v konfiguraci, v komentáříchdresscode:ignorei 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ěží.Structureje pro změny kódu (přepis výrazu, odstranění importu),Formattingpro bílé znaky a zalomení řádků,Cleanuppro 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. groupje skupina, ke které pravidlo patří, případ výčtuGroup. 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. Nechtenullu 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 jakorequirevcomposer.json:['php' => '>=8.1']u pravidla, které zapisuje syntaxi až od nějaké verze PHP (třeba0o755od 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.modifiesCommentsnastavte natruejen tehdy, když pravidlo opravdu mění text komentářů. JinakRuleTesterhlídá, že žádný komentář nezmizel ani se nezměnil, což je nejčastější chyba oprav.riskynastavte natrue, 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-callneví, jestli kód na volném porovnání vin_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 zafterFile(), ne zenter()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 ptejteToken::hasComment(),Token::hasCommentUpTo()neboNode::hasComment(); odstraňujte komentář jen přesremoveTrivia(). - Konec řádku, který uzavírá řádek tokenu, patří do jeho koncových trivia, ne do úvodních trivia dalšího tokenu.
Metody
ensureLeadingNewline()asetBlankLinesBefore()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()aNode::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.