PHP 8.6 vychází 19. listopadu. DressCode pro něj kód přepisuje už teď. 16 dní Přechod na novější PHP →

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ší:

  • NodeRule navštěvuje vybrané uzly a tokeny syntaktického stromu a pracuje s nimi v metodách enter() a leave(). 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áze Structure slouží ke strukturálním změnám kódu (přepis výrazů, mazání importů), Formatting řeší bílé znaky a zalomení řádků a Finishing obstará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.
  • modifiesComments nastavte na true pouze tehdy, pokud pravidlo záměrně mění text komentářů. V opačném případě RuleTester přísně hlídá, aby žádný komentář nezmizel ani se nepozměnil, což bývá nejčastější chyba neopatrných oprav.
  • requires definuje požadavky pravidla na prostředí projektu ve stejné syntaxi jako sekce require v composer.json: například ['php' => '>=8.1'] u pravidel zapisujících novější syntaxi (třeba osmičkový prefix 0o755), ['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 jeho composer.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.
  • typesRequired nastavte na true u pravidel, která bez typové analýzy vůbec nemají smysl – tedy bez analýzy Analyses\Types, kterou běh disponuje pouze s typeAnalysis: 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řes findAnalysis() (viz níže).
  • analyses jmenuje 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 s typesRequired: true zde samozřejmě uvede Analyses\Types. RuleTester testuje 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.
  • decisions uvá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říklad file.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í).
  • reads jmenuje 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říklad NoDeprecatedMembersRule mlčí o členu, který už pokrývá mapa upgrading.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á hodnotu keep, a pokud jej nikdo v konfiguraci ani v presetu neuvede, nevyžaduje nic. Výchozí hodnotu proto z principu nemá a konstruktor Decision by ji odmítl.
  • Parametr (parameter: true) pouze upřesňuje chování požadavku, sám o sobě nic nezapíná a hodnotu keep nepř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č --only jej 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 metody afterPass(), nikdy přímo z metody enter() 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í metody of() příslušné třídy (FunctionCallNode::of(), NewNode::of() a další). Výraz vkládaný na místo jiného výrazu nahrazujte metodou ExpressionNode::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 na Token::hasComment(), Token::hasCommentUpTo() nebo Node::hasInnerComment(); komentář odstraňujte výhradně přes removeTrivia().
  • 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é metody ensureStartsLine() a setBlankLinesBefore() 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() a ExpressionNode::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í instanci Analyses\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_PAID i self::STATUS_PAID vrá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ží metoda findCallableMethodAccess().
  • findDeprecation($member) vrátí instanci Analyses\Deprecation popisující obsah anotace @deprecated, nebo null.
  • isOfType($expression, 'array') vrátí stav výčtu Tristate urč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.

Zobrazit na GitHubu · Nahlásit problém se stránkou na GitHubu · 2. 11. 2026