Pravidlo do detailu
Co musí bezpodmínečně dodržet každé pravidlo: deklarovat svá rozhodnutí, provádět úpravy až po
schváleném ohlášení, být zcela bezstavové a idempotentní a správně nastavit atribut RuleInfo. Většinu
těchto zásad automaticky hlídá jádro nebo RuleTester – a právě díky tomuto pevnému kontraktu mohou
všechna pravidla běžet vedle sebe bez jakýchkoliv umělých priorit.
Dvě podoby pravidla
Základem každé kontroly v DressCode je třída odvozená od DressCode\Rule. Existuje ve dvou odlišných
podobách podle toho, jaký typ úloh řeší:
NodeRulenavštěvuje vybrané uzly a tokeny syntaktického stromu a pracuje s nimi v metodáchenter()aleave(). Tímto způsobem je napsaná naprostá většina pravidel a věnuje se jim celá tato stránka.GapRuležádné uzly přímo neprochází. Pouze deklarativně vznáší nároky na podobu bílých znaků mezi tokeny a jejich vyhodnocení nechává na jádru nástroje. Píše se zcela jinak a má vlastní stránku.
Každá třída je vždy striktně buď jedním, nebo druhým typem. Pokud byste potřebovali obojí, jde ve skutečnosti o dvojici spolupracujících pravidel, z nichž každé obsluhuje svá vlastní rozhodnutí.
Tvar pravidla
Pravidlo dědí od DressCode\NodeRule a nese povinný atribut #[RuleInfo]:
#[RuleInfo(
Stage::Structure,
modifiesComments: false,
requires: [],
typesRequired: false,
decisions: [],
)]
final class NoDebugCallsRule extends NodeRule
{
// ...
}
Všimněte si, že atribut neobsahuje název ani popis pravidla. Pravidlo je identifikováno přímo svou třídou a uživatel v konfiguraci nikdy nepracuje s pravidlem, nýbrž s jeho rozhodnutími – a každé rozhodnutí nese svůj vlastní popis.
- Fáze (
Stage) určuje, ve které ze tří fází průchodu pravidlo běží. FázeStructureslouží ke strukturálním změnám kódu (přepis výrazů, mazání importů),Formattingřeší bílé znaky a zalomení řádků aFinishingobstarává závěrečné začištění (odsazení, konce řádků, mezery na koncích, délku řádků, komentáře a phpDoc). Fáze probíhají striktně v tomto pořadí, takže formátování už pracuje s kódem po strukturálních úpravách. modifiesCommentsnastavte natruepouze tehdy, pokud pravidlo záměrně mění text komentářů. V opačném případěRuleTesterpřísně hlídá, aby žádný komentář nezmizel ani se nepozměnil, což bývá nejčastější chyba neopatrných oprav.requiresdefinuje požadavky pravidla na prostředí projektu ve stejné syntaxi jako sekcerequirevcomposer.json: například['php' => '>=8.1']u pravidel zapisujících novější syntaxi (třeba osmičkový prefix0o755),['acme/mailer' => '>=3.3']u pravidel pro konkrétní API knihovny,['acme/mailer' => '*'], pokud stačí pouhá přítomnost balíčku, nebo['acme/mailer' => '>=3.3 <5.0']u pravidel pro konstrukci, kterou novější verze knihovny opět odstranila. Samotnou verzi bez operátoru atribut odmítne, protože by pravidlo zbytečně vyřadila pro všechna ostatní vydání. Požadavek musí splňovat celý rozsah verzí podporovaný projektem v jehocomposer.json. Kde projekt podmínku nesplňuje, tam se pravidlo tiše vynechá, aniž by to musel řešit preset či sada. Základní podporovanou verzí je PHP 8.0, takže na prvky dostupné od verze 8.0 se ptát nemusíte.typesRequirednastavte natrueu pravidel, která bez typové analýzy vůbec nemají smysl – tedy bez analýzyAnalyses\Types, kterou běh disponuje pouze stypeAnalysis: phpstan. Pokud typy nejsou k dispozici, rozhodnutí zapnuté presetem se tiše neuplatní; pokud jej však uživatel zapíše do konfigurace sám, skončí běh chybou konfigurace. Pravidlo, kterému typy pouze pomáhají, tento parametr nenastavuje a ověří si jejich přítomnost přesfindAnalysis()(viz níže).analysesjmenuje každou analýzu, na kterou se pravidlo dotazuje (včetně analýz, které volají jeho pomocné třídy), například[Types::class, NameResolver::class]; pravidlo stypesRequired: truezde samozřejmě uvedeAnalyses\Types.RuleTestertestuje fixtury v přísném režimu a neregistrovanou analýzu odmítne – závislosti pravidla jsou tak přehledně vidět přímo v jeho atributu.decisionsuvádí cesty rozhodnutí, která pravidlo samo nedeklaruje, ale řídí se jimi: aktivují jej stejně jako jeho vlastní rozhodnutí a pravidlo pod nimi hlásí nálezy. Typicky jde o globální rozhodnutí jádra (napříkladfile.lineLength, aby pravidlo zalamovalo konstrukce při stejné šířce jako zbytek systému) nebo o rozhodnutí sdílená mezi více pravidly jednoho pluginu (viz Sdílené rozhodnutí).readsjmenuje rozhodnutí, která pravidlo pouze pasivně čte, ale samo je nevymáhá: neaktivují jej a pravidlo pod nimi nehlásí, ale najde jejich hodnoty v metoděconfigure(). Díky tomu může přenechat řešení mapě jiného pravidla – napříkladNoDeprecatedMembersRulemlčí o členu, který už pokrývá mapaupgrading.api.replacedMembers.
Informace o rizikovosti do RuleInfo nepatří. Rizikovost je vždy vlastností konkrétního výskytu v kódu a
pravidlo ji uvádí až při samotném volání report() – i kdyby byla riziková každá jeho oprava (viz níže).
Rozhodnutí, která pravidlo prosazuje
To, co pravidlo v kódu hlídá a vynucuje, ohlásí statickou metodou getDecisions() jako pole instancí
DressCode\Decision. Pravidlo musí mít alespoň jedno rozhodnutí (buď vlastní, nebo převzaté v atributu pod
decisions): každé porušení se totiž hlásí pod konkrétním rozhodnutím a pravidlo bez rozhodnutí DressCode
okamžitě odmítne chybou Rule `Acme\CodeStyle\NoDebugCallsRule` declares no decision. Rozhodnutí všech pravidel
(z jádra, pluginů i projektu) dohromady tvoří katalog: podle něj se validuje konfigurace, čerpá z něj
dresscode explain i dresscode catalogue a hledají se překlepy v klíčích.
public static function getDecisions(): array
{
return [
new Decision('acme.debugCalls', Domain::state('forbidden'), 'A statement calling a debugging function is removed'),
];
}
Cesta představuje jednoznačný identifikátor, pod kterým uživatel rozhodnutí potkává: je to konfigurační
klíč, identifikátor nálezu ve výpisu, název v komentáři dresscode:ignore, v fixRisky, v
severity i v souboru baseline. První segment tvoří sekce. Projektová pravidla sídlí v sekci
project, pravidla pluginu v sekci pojmenované podle manifestu pluginu (PluginManifest::$section) a
jádro ve svých vlastních sekcích. Cestu mimo povolenou sekci katalog odmítne, stejně jako duplicitu ohlášenou dvěma
různými pravidly. Cesta vždy pojmenovává prvek v kódu, nikoliv krok opravy, a hodnota říká, jak se má zapsat: pište
tedy acme.debugCalls: forbidden, nikoliv acme.removeDebugCalls: true.
Popis je anglická věta vystihující chování pravidla tam, kde hodnota není keep; nekončí tečkou a
kód v ní patří do zpětných apostrofů. Parametr notes umožňuje přidat doplňující věty o tom, čeho se
rozhodnutí netýká; dresscode explain je vypíše pod hlavním popisem.
Rozhodnutí existují ve třech druzích:
- Požadavek (requirement) aktivuje pravidlo: pravidlo běží všude tam, kde má některý z jeho požadavků jinou
hodnotu než
keep. Každý požadavek automaticky přijímá hodnotukeep, a pokud jej nikdo v konfiguraci ani v presetu neuvede, nevyžaduje nic. Výchozí hodnotu proto z principu nemá a konstruktorDecisionby ji odmítl. - Parametr (
parameter: true) pouze upřesňuje chování požadavku, sám o sobě nic nezapíná a hodnotukeepnepřijímá. Namísto toho má povinnou výchozí hodnotu (default:). Seznam výjimek či číselný práh jsou typickými parametry – jejich pouhé uvedení v konfiguraci nesmí pravidlo aktivovat. - Fakt (
fact: true) popisuje reálné prostředí a pravidlo jej pouze hlídá (například seznam deklarací v namespaces projektu). Přepínač--onlyjej nikdy neomezí.
new Decision('acme.debugMethods', new Names, 'The methods whose calls count as debugging calls, as `Class::method`', parameter: true, default: []);
Obor hodnot (Domain) určuje, jaké hodnoty klíč přijímá, jak se hodnota normalizuje a jak se
slučují hodnoty z různých konfiguračních vrstev:
| obor | co bere | příklad v konfiguraci |
|---|---|---|
Domain::state('forbidden'), state('required'), state('required', 'forbidden') |
přítomnost: prvek v kódu musí, nebo nesmí být; v sekci upgrading hodnota required zapisuje
novou syntaxi |
acme.debugCalls: forbidden |
Domain::placement() |
umístění tokenu: sameLine nebo nextLine |
braces.position.class: nextLine |
new Words([...]) |
klíčové slovo z výčtu, každé s popisem významu; s tolerance: true i seznam slov, z nichž projde
každé a první se použije při zápisu |
literals.quotes: single |
new Shapes([...]) |
tvar konstrukce zapsaný ukázkou kódu v apostrofech; pojmenování čte pouze pravidlo | spacing.call: 'foo($a, $b)' |
new Count, Domain::blankLines() |
celé číslo, rozsah 1-2, otevřený rozsah 1+, případně slovo jako none |
blankLines.aroundMethods: 2 |
new Names |
seznam identifikátorů, vzorů nebo pořadí; symbol ... zastoupí seznam z nižší vrstvy |
mistakes.debugOutputFunctions: [..., dump, bdump] |
new Map($domain) |
mapa názvů či vzorů s * na hodnoty jiného oboru |
blankLines.beforeStatement: {return: 1} |
new Flag, new Text |
booleany yes/no (pouze pro parametry) a volný textový řetězec |
Hodnotu keep žádný obor do svého výčtu neuvádí: přijímá ji každý požadavek automaticky, mapa ji bere
i po jednotlivých položkách a na úrovni celé sekce či skupiny platí pro vše pod ní. Obor hodnot vůbec neřeší
závažnost hlášení – to je výhradní věcí klíče severity v konfiguraci. Vlastní slovo pro „nesahej na
to“ si tedy nevymýšlejte; existuje pouze univerzální keep.
Hodnoty v pravidle
Finální hodnoty platné pro daný soubor obdrží pravidlo v metodě configure() v podobě objektu
Values. Jádro tuto metodu zavolá jednou pro každou unikátní kombinaci vrstev, do které soubory patří.
Pravidlo si v ní předpočítá potřebná data a uloží je do svých vlastností:
public function configure(Values $values): void
{
$names = $values->get('mistakes.debugOutputFunctions')->getNames();
$this->functions = array_fill_keys(array_map(strtolower(...), $names), true);
}
Volání $values->get($path) vrátí objekt Value reprezentující výslednou efektivní hodnotu
daného rozhodnutí (u parametrů jejich výchozí hodnotu, pokud nebyla změněna). Z objektu Value se hodnota
čte metodou odpovídající jejímu oboru: getWord(), getShape(), getCountRange(),
getNames(), getEntries(), getFlag(), getText(). Rozhodnutí, které může
nabývat stavu keep, čtěte pomocí $values->find($path): pokud projekt nechal rozhodnutí
neaktivní, vrátí null (pravidlo pak zapíše např. $values->find($path)?->getWord()). Dotaz
$values->isKept($path) vrátí totéž jako boolean a $values->has($path) ověří, zda katalog
rozhodnutí vůbec zná (což v běhu zúženém pomocí --only platí pouze pro vybraná rozhodnutí). Cesta,
kterou katalog nezná, vyvolá u get() i find() výjimku, nikdy nevrátí null.
Pravidlo smí číst i cizí rozhodnutí. Ukázka výše si bere seznam ladicích funkcí z parametru jádra
mistakes.debugOutputFunctions, který už projekt mohl mít nastavený, namísto zavádění vlastního duplicitního
klíče. Projekt tak jediným seznamem určí ladicí funkce pro všechna pravidla najednou. Podobně se pravidlo generující
nový kód může dotázat na preferovaný typ uvozovek.
Metoda $values->isSelected($path) odpovídá na jinou otázku: zda se daný požadavek v tomto konkrétním
běhu aktivně hlásí a opravuje. Rozdíl vzniká při použití přepínače --only, který zúží záběr běhu,
aniž by měnil hodnoty v konfiguraci. Kód, který pravidlo zapisuje, se proto vždy řídí metodou get() (tedy
celkovou konfigurací), a isSelected() slouží výhradně k optimalizaci, kdy si pravidlo chce ušetřit práci
s požadavkem, který se v daném běhu nehlásí.
Pravidlo s jediným požadavkem hlásí nálezy automaticky pod ním a cestu do metody report() nepředává.
Pravidlo obsluhující více požadavků uvádí cestu u každého volání:
$context->report($node, 'The `dd()` call must not stay in the code.', decision: 'acme.debugCalls');
Hlášení pod požadavkem, který má hodnotu keep nebo jej přepínač --only vyřadil,
nezaznamená žádnou chybu a vrátí false. Pravidlo tak nemusí stav před každým voláním report()
složitě prověřovat. Nahlášení pod cestou, kterou pravidlo nedeklarovalo, nebo pod parametrem, je porušením kontraktu.
Pravidlo, u něhož cílová verze PHP rozhoduje o tom, jakou syntaxi smí zapsat (nikoliv o tom, zda vůbec běží), si ji
přečte z $context->phpVersion. Jde o řetězec ve formátu major.minor, který se porovnává
funkcí, nikoliv běžnými operátory (řetězec '8.10' totiž není větší než '8.9'):
if (version_compare($context->phpVersion, '8.4', '>=')) {
// ...
}
Mapa s daty
Pravidlo, které přepisuje kód podle rozsáhlých seznamů od projektu či knihoven, využívá mapu: obor Map
s hodnotami Data a validačním schématem, podle něhož se položky po sloučení vrstev načtou. Přesně takto
funguje rozhodnutí nette.flagsAsNamedArguments z balíčku dresscode/upgrade-nette: klíčem jeho mapy
je signatura volání a hodnotou bitové příznaky převedené na pojmenované argumenty:
public static function getDecisions(): array
{
return [new Decision('nette.flagsAsNamedArguments', new Map(new Data, grammar: self::createGrammar()), 'A call passing the named arguments its method takes for the flags of an integer')];
}
public function configure(Values $values): void
{
$this->map = MemberMap::fromValues($values, 'nette.flagsAsNamedArguments', self::readFlags(...));
}
Gramatika představuje validační schéma z knihovny nette/schema; pro klíče
se členy a signaturami argumentů jej sestaví pomocník Upgrading\MemberMapGrammar::createMapSchema(), takže mapa
v pluginu parsuje klíče naprosto shodně jako mapy jádra. Metoda $values->readMap() vrátí položky
zvalidované schématem a Upgrading\MemberMap::fromValues() z nich vytvoří efektivní vyhledávací mapu podle
názvu člena: getEntries() vrátí položky daného jména, has() ověří přítomnost a
findCall() dohledá položku volání podle tvaru předaných argumentů. Tuto mapu může plnit jak uživatel
v konfiguraci, tak soubory s daty z balíčků.
Které uzly a kdy
public function getVisitedNodes(): array
{
return [FunctionCallNode::class];
}
Metoda vrací pole tříd uzlů (nebo Token::class), pro které má jádro volat metody enter() a
leave(). Vyhodnocení probíhá přes operátor instanceof, takže StatementNode::class
zachytí jakýkoliv příkaz a Node::class zachytí úplně všechno. Čím užší seznam tříd zvolíte, tím
rychlejší bude průchod souborem. Prázdné pole značí, že pravidlo pracuje výhradně v metodách beforePass()
a afterPass(), které jádro volá na začátku a na konci každého průchodu nad souborem (takto pracují
celosouborové kontroly).
Metoda enter() se volá při vstupu do uzlu (tedy před návštěvou jeho potomků), metoda leave()
až při odchodu po zpracování potomků. Pokud pravidlo uzel v metodě enter() nahradí nebo zcela smaže, jádro
do něj již nesestupuje a metodu leave() pro něj nevolá.
Nejdřív ohlásit, teprve pak opravit
Toto je nejdůležitější pravidlo celého vývojářského kontraktu: syntaktický strom se smí upravit výhradně
poté, co metoda report() vrátila true.
$statement = $node->parent;
if (
$statement instanceof ExpressionStatementNode
&& $statement->parent instanceof NodeList
&& $context->report($node, 'The `var_dump()` call must not stay in the code.')
) {
$statement->remove();
}
Ukázka bezpečně odstraní celý příkaz obalující volání, protože metoda remove() umí smazat pouze prvek
seznamu. Zda je to technicky možné, si pravidlo ověří ještě před zavoláním report(): volání uvnitř
složitějšího výrazu nebo příkaz ve slotu (například za if bez složených závorek) nechá raději
netknuté.
Metoda report() vrátí false, pokud je nález na daném řádku potlačen komentářem, nebo pokud
jde o rizikovou opravu, kterou konfigurace nepovolila. V takovém případě se kód nesmí nijak změnit. Nález evidovaný
v souboru baseline metoda report() nezamítne: chyba se sice ve výstupu nezobrazí, ale pravidlo má plné právo
ji opravit. Jádro se přitom nespoléhá na disciplínu autora: syntaktický strom si interně počítá každou změnu a jádro
každou úpravu striktně páruje s hlášením z téhož volání. Změna provedená bez předchozího hlášení (nebo po
hlášení, které vrátilo false) představuje porušení kontraktu. Za běžného běhu vyvolá varování,
s přepínačem --strict-rules a v prostředí RuleTester pak fatální chybu.
Oprava, která by mohla změnit chování programu, se hlásí s parametrem risk přímo u daného konkrétního
výskytu. Hodnota parametru určuje, co by o bezpečnosti opravy dokázalo rozhodnout: Risk::TypeUnknown značí
neznámý typ hodnoty (který neznají deklarace ani typy z PHPStanu),
Risk::NameUncertain nejistotu ohledně funkce v namespace (kterou by vyřešilo nameResolution: certain) a
Risk::BehaviorChanges lidský úsudek tam, kde oprava mění chování ze své podstaty nebo závisí na externím
kódu. Parametr because doplňuje větu vysvětlující, co konkrétního se může pokazit; bez parametru
risk jej přísný test odmítne, protože není co vysvětlovat. Tato věta stojí vždy vedle chybové zprávy,
nikdy přímo v ní: samotná zpráva popisuje porušení stylu a nesmí se měnit podle toho, zda má běh zrovna k dispozici
typovou analýzu.
if ($context->report($foreach, 'The loop must be written with `array_any()`.', risk: Risk::TypeUnknown,
because: 'the loop may go through an object, which `array_any()` does not take')) {
// ...
}
Takové hlášení vrátí false do chvíle, než projekt dané rozhodnutí povolí v klíči
fixRisky nebo běh dostane přepínač --fix-risky. Do té doby pravidlo kód nechá být a nález
zůstane ve výpisu neopravených míst (u TypeUnknown a NameUncertain jako varování, u
BehaviorChanges jako chyba). Totéž pravidlo tak v témže průchodu bezpečný výskyt opraví a rizikový pouze
ohlásí; i pravidlo, jehož každá oprava je riziková, uvádí risk u každého volání. Fixtura s hlavičkou
// risky demonstruje chování pravidla při povolených rizikových opravách a slouží jako důkaz, že pravidlo
takové opravy nabízí.
Riziková oprava smí pozměnit chování kódu, ale nikdy nesmí vygenerovat nevalidní syntaxi, kterou PHP nepřeloží. Kde by o syntaktické validitě rozhodoval externí soubor, který projekt nezná (například dceřiná třída v jiné knihovně), tam musí pravidlo zcela mlčet: zde se na svolení ptát nelze.
Oprava chrání chování reálně fungujícího kódu. Za rizikovou se proto nepovažuje oprava, která pouze ušetří chybu
či varování, k němuž by původní kód dospěl až za místem, kde už zná výsledek: například array_any()
namísto count(array_filter()) skončí u prvního prvku splňujícího podmínku a zbytek pole vůbec
nevyhodnocuje. Riziková není ani oprava kódu spoléhajícího na chování, které PHP ve všech cílových verzích označuje
za zastaralé.
Kde bezpečnost závisí na typu hodnoty, odpovídá pravidlo jedním ze tří stavů výčtu Tristate: jisté ano
(oprava je bezpečná), jisté ne (pravidlo mlčí, protože riziková oprava nesmí být chybnou radou) a nevím (oprava je
riziková s Risk::TypeUnknown). Odpověď poskytnou nejprve viditelné deklarace (typ parametru, vlastnosti či
návratový typ) a co zůstane otevřené, to upřesní typy z PHPStanu.
Výskyt, pro který pravidlo nemá opravu a pouze jej hlásí, označte parametrem fixable: false. Metoda
report() pak vrátí false vždy a hlášení se nikdy nepovažuje za rizikové. Jinak by neopravitelný
nález s příznakem rizika vypadal jako oprava čekající na svolení a souhrn by zbytečně radil použít
--fix-risky. Například pravidlo mistakes.strictArgument explicitně zapsanou hodnotu
false ve volání in_array() pouze hlásí, protože ji autor zapsal vědomě:
$context->report($strict, 'The `in_array()` call must pass `$strict = true`.', fixable: false);
Na zapomenutou opravu vás okamžitě upozorní RuleTester: hlášení bez fixable: false, které
v kódu zůstane i po závěrečném ustáleném průchodu, porušuje kontrakt a fixtura selže. Stejně tak selže fixtura
označená // risky, pokud po jejím doběhnutí zůstane neprovedená riziková oprava, přestože byla výslovně
povolena.
Hlášení se váže na konkrétní uzel nebo token. Problém spočívající v bílých znacích či komentáři nahlaste
s příslušnou položkou trivia (bílým znakem či komentářem obklopujícím token) přes
report($token, $message, trivia: $trivia) – nález tak získá přesné číslo řádku dané trivia a komentář
dresscode:ignore jej na tomto řádku spolehlivě najde. Závažnost pravidlo neurčuje: každé porušení je
automaticky chybou, dokud jej konfigurace nezměkčí na varování klíčem severity.
Dva doplňující parametry metody report() řeší situace, kdy chyba vznikla až v důsledku opravy jiného
nálezu v tomtéž běhu. Pravidlo zarovnávající řádek podle jiného řádku (např. odsazení) předá v parametru
follows: token otevírající onen vzorový řádek; pravidlo posuzující formátování celého řádku předá
byLine: true. Pokud oprava v témže běhu daný řádek nově zalomila či posunula, terminál nález vypíše
přehledně pod původní chybou jako návazný řádek Then also. Zda by bylo hlášení na daném místě
potlačeno komentářem, ověří metoda $context->isSilenced(), aniž by cokoliv zaznamenávala (přebírá
stejné argumenty: isSilenced($at, $decision, trivia: $trivia)).
Text chybové zprávy popisuje stav kódu, nikoliv programátora, a nikdy nerozkazuje. Jde o celou anglickou větu končící
tečkou. Využívá jeden ze tří standardních tvarů: očekávaný stav
(Expected a single space after the comma.), norma (The opening brace must be on its own line.), nebo
zjištěný fakt (Function `foo()` is deprecated.). Konkrétní identifikátory a hodnoty do zprávy patří,
samotná cesta rozhodnutí nikoliv (tu automaticky doplní výpis). Vše, co představuje kód (názvy tříd, funkcí,
proměnných, operátory či hodnoty), patří do zpětných apostrofů jako v Markdownu. Kód obsahující zpětné apostrofy
(výrazy, řetězce či komentáře) vkládejte přes pomocnou metodu Violation::formatCode($code), která jej
bezpečně obalí vícenásobnými apostrofy.
Bez stavu a idempotentní
Jediná instance pravidla slouží pro celý běh a zpracovává všechny soubory. Stav vázaný na průchod jedním
konkrétním souborem patří výhradně do pole $context->passStorage, které jádro před každým průchodem
vytvoří prázdné; vlastnosti třídy slouží pouze pro neměnná data předpočítaná v metodě configure().
Vše potřebné o aktuálním souboru najdete ve vlastnostech kontextu: $context->file je kořenový syntaktický
strom (FileNode), $context->path relativní cesta, $context->style definovaný styl
(odsazení, zakončení řádků, šířka tabulátoru, maximální délka řádku maxLineLength, pravidla
zalamování lineSplits a podoba importů imports) a $context->phpVersion cílová
verze PHP.
Pravidla se spouštějí opakovaně, dokud dochází ke změnám stromu. Z toho důvodu musí být každé pravidlo striktně
idempotentní: spuštěné nad svým vlastním opraveným výstupem už nesmí změnit ani bajt. Zároveň nesmí záviset
na pořadí průchodu ani na tom, o kolikátý průchod v pořadí zrovna jde (kontext vám tuto informaci záměrně
neposkytne). Pokud by se dvě pravidla přetahovala v nekonečné smyčce, jádro to okamžitě detekuje a běh zastaví
s vypsáním obou kolidujících pravidel. Idempotenci automaticky prověřuje i RuleTester: spustí pravidlo nad
jeho vlastním výstupem podruhé a selže, pokud dojde k jakékoliv další úpravě.
Co strom dovolí
Slot uzlu se upravuje běžným přiřazením ($if->condition = $expression), celý uzel se nahrazuje metodou
replaceWith() a odstraňuje voláním remove(). Text a trivia tokenu mění jeho vlastní metody.
O provázání rodičovských vazeb a o tokenový index se syntaktický strom stará zcela sám – strom tedy nelze rozbít
ani neopatrným zápisem mimo API (podrobnosti najdete na stránce Úpravy
stromu). Pro pravidla navíc platí tyto zásady:
- Sourozence v seznamu měňte výhradně z callbacku jejich společného vlastníka (
FileNode,BlockNode,ClassNode) nebo z metodyafterPass(), nikdy přímo z metodyenter()právě procházaného prvku. Jádro procházený seznam za běhu nepřepočítává. - Pravidlo smí nahradit pouze uzel, který samo obdrželo, nebo některého z jeho potomků – nikdy ne svého předka.
- Nové konstrukce nestavte manuálním lepením tokenů. Využijte vestavěný parser (
Parser::parseExpression(),parseStatement(),parseType(),parseName()), nebo uzel poskládejte z existujících prvků pomocí tovární metodyof()příslušné třídy (FunctionCallNode::of(),NewNode::of()a další). Výraz vkládaný na místo jiného výrazu nahrazujte metodouExpressionNode::replaceWithExpression(), která automaticky vyřeší potřebné uzávorkování. - Komentáře nesmí nikdy zmizet, pokud pravidlo nemá nastaveno
modifiesComments: true. Před jakýmkoliv zásahem ověřte přítomnost komentářů dotazem naToken::hasComment(),Token::hasCommentUpTo()neboNode::hasInnerComment(); komentář odstraňujte výhradně přesremoveTrivia(). - Znak konce řádku, který uzavírá řádek daného tokenu, patří vždy do jeho koncových trivia
(
trailingTrivia), nikoliv do úvodních trivia následujícího tokenu. Pomocné metodyensureStartsLine()asetBlankLinesBefore()to dělají správně, proto stavte na nich. - Na otázky „je tento výraz ekvivalentní tamtomu?“ a „lze tento výraz bezpečně vyhodnotit dvakrát?“ spolehlivě
odpovídají metody
Node::matches()aExpressionNode::isRepeatableRead(). Neimplementujte tyto kontroly znovu.
Analýzy
Komplexní informace o souboru, které potřebuje sdílet více různých pravidel, patří do samostatné analýzy. Jde
o běžnou třídu, jejíž konstruktor přijímá FileNode (případně nemá žádné parametry). Pravidlo si ji
vyžádá přímo z kontextu:
$resolver = $context->getAnalysis(NameResolver::class);
if ($resolver->isGlobalFunctionCall($node, 'var_dump')) {
// ...
}
Jádro vytvoří instanci analýzy při prvním dotazu a drží ji v paměti, dokud se syntaktický strom nezmění. Po
jakékoliv úpravě stromu ji zahodí a při dalším dotazu vytvoří čerstvou instanci. Instance, kterou by si pravidlo
uložilo do lokální proměnné, však vypovídá o stavu stromu před úpravou. Pravidlo proto po provedení změn musí
zavolat getAnalysis() znovu, nebo si potřebná data vytáhnout ještě před prvním zásahem. Výjimku tvoří
analýzy implementující rozhraní Analyses\PassAnalysis: ty jádro spočítá jednou na začátku průchodu a
drží je neměnné až do jeho konce i přes průběžné úpravy stromu (přepočítávat je po každé drobné změně by bylo
neúnosně drahé). Uzel nově vložený během průchodu v nich tedy záznam nemá a pravidlo se na něj musí dotazovat
bezpečně přes operátor ?->. Vestavěné analýzy zahrnují PhpSyntax\Analyses\NameResolver
(jmenné prostory, importy, překlad názvů), PhpSyntax\Analyses\Scope (kontext funkcí, tříd, dostupnost
$this), DressCode\Analyses\PhpDoc (AST dokumentačních komentářů), PhpSymbols (funkce,
třídy a konstanty deklarované samotným PHP), PhpSignatures (signatury parametrů vestavěných funkcí) a
Types (typová analýza z PHPStanu projektu, jediná vestavěná PassAnalysis). Vlastní analýza
s konstruktorem přijímajícím FileNode se nikde registrovat nemusí. Pouze analýza vyžadující specifickou
inicializaci potřebuje továrnu (factory), která obdrží strom a cestu k souboru; plugin ji registruje v poli
analyses svého manifestu, projekt v konfiguračním klíči analyses.
Volání getAnalysis() na analýzu, kterou daný běh nemá k dispozici, okamžitě selže. Typovou analýzu má
běh k dispozici pouze při zapnutém typeAnalysis: phpstan. Pravidlo, pro které jsou typy pouze volitelným
vylepšením, se proto dotazuje přes $context->findAnalysis(Types::class), které při absenci PHPStanu
bezpečně vrátí null.
Pravidla se mezi sebou nikdy nedotazují napřímo. Co dvě pravidla téhož pluginu potřebují sdílet, to představuje
rozhodnutí z manifestu pluginu: pravidlo, které jej vymáhá, jej uvede v decisions, a pravidlo, které jej pouze
čte, v reads. Metody vestavěných pravidel nejsou veřejným API; společnou funkcionalitu nabízí pomocník
CodeWriter (viz níže).
Typy kódu
Analýza Types odpovídá na sémantické otázky, na které samotný syntaktický strom odpověď nezná.
Samotné interní typové struktury PHPStanu zůstávají zapouzdřené uvnitř; pravidlo klade konkrétní dotazy:
findMember($node)vrátí instanciAnalyses\Member, tedy konstantu, metodu, vlastnost či konstruktor, na který daný výraz sahá (včetně třídy, jež prvek deklaruje). Rozhoduje skutečná deklarace, nikoliv zápis v kódu: zápisy$order::STATUS_PAID,Order::STATUS_PAIDiself::STATUS_PAIDvrátí identickýMember.findMemberAccess($node)vrátíAnalyses\MemberAccess: přístup ke členu podle typu volaného objektu, a to i k prvkům, které už žádná třída v nové verzi nedeklaruje. Tuto metodu využívají pravidla migrující odstraněné API knihoven. Volání v podobě pole callable ([$order, 'paid']) přeloží metodafindCallableMethodAccess().findDeprecation($member)vrátí instanciAnalyses\Deprecationpopisující obsah anotace@deprecated, nebonull.isOfType($expression, 'array')vrátí stav výčtuTristateurčující, zda je výraz daného typu; syntaxe odpovídá phpDocu (int|string,list<int>), třídy musí být plně kvalifikované. Tuto metodu volejte pouze tehdy, pokud požadavek nepokrývá žádný ze specializovaných dotazů výše.
$types = $context->getAnalysis(Types::class);
$member = $types->findMember($node);
$deprecation = $member === null ? null : $types->findDeprecation($member);
if ($deprecation !== null) {
$context->report($node, $member->describe() . ' is deprecated' . $deprecation->formatDescription() . '.', fixable: false);
}
Nad třídou Acme\Shop\Order, jejíž konstanta nese anotaci @deprecated use Order::StatusPaid,
ohlásí tato ukázka u volání $order::STATUS_PAID přesnou zprávu
Constant `Acme\Shop\Order::STATUS_PAID` is deprecated: use Order::StatusPaid.. Pravidlo, kterému typy pouze
zpřesňují rozhodování, volá findAnalysis() namísto getAnalysis() a případnou nejistotu
ošetří samo: $context->findAnalysis(Types::class)?->isOfType($expression, 'array') ?? Tristate::Maybe.
V běhu bez typů pak nic nerozbije – pouze více sporných výskytů nahlásí jako rizikové nebo je ponechá
beze změny.
Jména funkcí a tříd
Analýza NameResolver ví i to, co z jednoho izolovaného souboru nelze vyčíst: jaké funkce a konstanty
projekt reálně deklaruje v jmenných prostorech (podle konfiguračních klíčů namespaces a
nameResolution). Nekvalifikované volání v namespace totiž může mířit na lokální funkci daného namespace,
a pokud projekt tuto možnost nevyloučil nastavením nameResolution: certain, stojí každá oprava takového
volání na pouhém předpokladu. Třída GlobalCalls vám spolehlivě řekne obojí: jakou globální funkci kód
volá a jaké konkrétní riziko záměny hrozí:
if (!$node instanceof FunctionCallNode || GlobalCalls::findFunction($node, ['sizeof' => true], $context) === null) {
return;
}
$because = GlobalCalls::findUncertainty($node, $context);
if ($context->report($node, 'The `sizeof()` call must be written `count()`.', risk: $because === null ? null : Risk::NameUncertain, because: $because)) {
$node->name->text = CodeWriter::spellFunction('count', $node->name, $context);
}
Metoda findFunction() přebírá hledané názvy funkcí jako klíče asociativního pole (hodnoty nečte, takže
jí pravidlo může přímo předat mapu svých dat) a vrátí název volané funkce malými písmeny, nebo null.
Metoda findUncertainty() vrátí textové odůvodnění rizika (např.
the namespace may declare `sizeof()`), nebo null, pokud je volání prokazatelně bezpečné. Nový
název neskládejte ručně: pomocník CodeWriter::spellFunction() jej zapíše jako holý název, pokud byl
původní název holý a nová podoba nepřináší žádnou nejistotu; v opačném případě jej opatří úvodním lomítkem
(proč na tom záleží, rozebírá stránka Funkce a konstanty ve
jmenných prostorech).
Název třídy zapisujte přes CodeWriter::writeClass($class, $node, $context): vloží jej v nejkratší možné
podobě, jakou kontext souboru dovoluje, a v případě potřeby automaticky doplní import podle pravidel sekce
imports.* (podle stylu $context->style->imports) – a to i v běhu, který pravidla pro
importy přímo nespouští. Protože tato metoda může modifikovat syntaktický strom, volejte ji výhradně po metodě
report(), která vrátila true, nikdy při sestavování textu chybové zprávy. Název, který nic
nenahrazuje a má se pouze zkrátit, vrátí metoda NameResolver::shortenName().
Testování
Každé pravidlo musí mít připravené fixtury a testovací třídu RuleTester, která nad nimi automaticky
ověří správnost výstupu, přesnost hlášení i dodržení všech popsaných zásad kontraktu: Testování pravidel.