Pravidla pro bílé znaky
Pravidla hlídající mezery, zalomení řádků a prázdné řádky se píší zcela jinak než běžná pravidla: do zdrojového kódu vůbec nesahají. Pouze deklarativně určí, co má ležet v prostoru mezi dvěma tokeny, a o všechno ostatní se postará jádro nástroje.
Co je mezera mezi tokeny
Pojmem mezera mezi tokeny zde označujeme veškerý prostor ve zdrojovém kódu, který leží mezi dvěma sousedními
tokeny: může být zcela prázdný, nebo obsahovat jednu či více mezer, zalomení řádku, prázdné řádky i komentáře.
V programovém API tento prostor reprezentuje třída Gap.
$sum = $a + $b;
Tento krátký řádek obsahuje v tomto smyslu přesně pět mezer: mezi $sum a =, mezi
= a $a, mezi $a a +, mezi + a $b a konečně mezi
$b a ;. I ta poslední je plnohodnotnou mezerou mezi tokeny, jen v ní zrovna nic nestojí – a
i o ní může pravidlo prohlásit, že má zůstat prázdná.
Pravidlo pro bílé znaky (odvozené od třídy GapRule) o takovém místě pouze řekne, jak má vypadat. Samo
do něj nic nezapisuje, nic z něj nemaže ani v něm nic nevyhledává.
Proč pravidla bílé znaky nepřepisují
Kdyby pravidlo pro mezeru za čárkou vkládalo mezeru přímo do stromu a pravidlo pro zalamování dlouhých seznamů si samo vkládalo konce řádků, střetla by se na jednom místě dvě nezávislá pravidla a vyhrálo by to, které zrovna běželo jako poslední. V dalším průchodu by to klidně mohlo dopadnout opačně.
Právě proto pravidla pro bílé znaky do kódu přímo nezasahují. Každé z nich pouze vznese nárok (v API
třída Claim) na to, jak má dané místo vypadat. Jádro nástroje, které vidí všechny vznesené nároky
současně, pak centrálně rozhodne, upraví bílé znaky a případné porušení nahlásí pod rozhodnutím toho nároku,
který zvítězil.
Pro uživatele z toho plyne zásadní výhoda: každá mezera má vždy právě jednoho vlastníka a dvě pravidla, která by si nárokovala totéž místo protichůdným způsobem, představují chybu konfigurace. Dva pevné kolidující nároky nástroj odmítne hned při startu; nároky vrácené dynamicky z uzávěr (closures) pak odhalí přesně u té mezery, kde se srazí (viz níže).
Co nárok říká
Objekt nároku Claim se skládá ze čtyř nezávislých složek a pravidlo z nich vyplní pouze ty, na kterých
mu v daném kontextu záleží:
| složka | co říká | hodnoty |
|---|---|---|
space |
vodorovná mezera v případě, že oba tokeny leží na stejném řádku | Space::None, Single, AtLeastOne a varianty SingleOrTabs,
AtLeastOneOrTabs, které tolerují i tabulátory zarovnávající sloupce |
line |
zda druhý token stojí na stejném řádku jako první, nebo na novém řádku | Line::Same, Line::Next |
blankLines |
počet prázdných řádků v případě, že mezera obsahuje zalomení | přesný počet, nebo rozsah [min, max] (hodnota null značí otevřený konec) |
blankLinesBelowComment |
prázdné řádky mezi komentářem uvnitř mezery a následujícím tokenem | totéž |
Pro nejčastější nároky jsou připraveny tovární metody: Claim::noSpace(), singleSpace(),
singleSpaceOrTabs(), atLeastOneSpace(), atLeastOneSpaceOrTabs(), sameLine(),
nextLine() a blankLines(), která přijímá jak pevný počet (blankLines(1)), tak rozsah
(blankLines([1, 2])). Všechny tyto metody (s výjimkou blankLines() s rozsahem) vracejí sdílené
instance, takže nealokují zbytečnou paměť. Kombinovaný nárok na více složek současně nebo nárok s textovým
odůvodněním vytvoříte přímo konstruktorem: new Claim(Space::None, line: Line::Same) nebo
new Claim(line: Line::Next, because: 'the line is 135 characters long'). Uvedené odůvodnění jádro automaticky
připojí za čárku k chybové hlášce.
Prázdné řádky se počítají nad komentářem, který stojí na samostatných řádcích před tokenem –
komentář totiž logicky patří ke kódu pod sebou. Výjimku tvoří komentář těsně před zavírací závorkou bloku nebo
před koncem souboru: ten se váže ke kódu nad sebou. Řádky mezi posledním komentářem a samotným tokenem řídí
samostatná složka blankLinesBelowComment; tu využívá například pravidlo hlídající odstup mezi phpDoc
komentářem a deklarací třídy či metody.
Kde nárok platí
Pravidlo dědí od GapRule, nese atribut #[RuleInfo] jako každé jiné pravidlo a v metodě
getClaims() deklaruje, u kterých slotů jakých uzlů vznáší své nároky – a to vždy zvlášť pro mezeru
před hodnotou slotu a za ní:
#[RuleInfo(Stage::Formatting)]
final class ReturnBlankLinesRule extends GapRule
{
private Claim $claim;
public static function getDecisions(): array
{
return [
new Decision('acme.blankLinesBeforeReturn', Domain::blankLines(), 'Blank lines before a `return` that is not the first statement of its block'),
];
}
public function configure(Values $values): void
{
$this->claim = Claim::blankLines($values->get('acme.blankLinesBeforeReturn')->getCountRange());
}
public function getClaims(): array
{
return [
'*' => [
'statements:item' => [
fn(Gap $gap) => $gap->value instanceof ReturnNode && $gap->index > 0 ? $this->claim : null,
null,
],
],
];
}
}
Klíčem první úrovně je třída uzlu, případně zástupný znak * pro jakýkoliv uzel, který daný slot
obsahuje. Klíčem druhé úrovně je název slotu ('body'), případně zápis slot:item pro každou
položku seznamu ('statements:item', 'items:item') nebo slot:separator pro oddělovače
seznamu ('items:separator'). Pod zástupným klíčem * znamená '*:item' položky a
'*:separator' oddělovače úplně každého seznamu v kódu – přesně takto si rozhodnutí
spacing.comma nárokuje mezery kolem všech čárek. Hodnotou je dvouprvkové pole nároků [před, za]:
každým prvkem může být pevný objekt Claim, hodnota null (pokud pravidlo v daném místě nic
nežádá), nebo uzávěra (closure), která obdrží instanci Gap a vrátí nárok či null. Nárok
definovaný u slotu obsahujícího celý podstrom platí pro mezeru před jeho prvním tokenem (před) nebo za jeho posledním
tokenem (za).
Název slotu se uvádí jako řetězec, takže jej automatický refaktoring v IDE nepřejmenuje spolu s vlastností třídy.
Případný překlep však v tichosti neprojde: nárok na neexistující slot (např. 'statemnts:item') jádro
okamžitě při sestavování pravidel odmítne výjimkou ConfigurationException
(Rule `Acme\CodeStyle\ReturnBlankLinesRule` claims the whitespace of `*.statemnts:item`, but no node has a slot `statemnts`.).
Přesné názvy všech slotů najdete v přehledu uzlů.
Ukázka výše vyjadřuje jasné pravidlo: před každým příkazem return v seznamu příkazů, který není na
prvním místě v bloku, musí být tolik prázdných řádků, kolik určuje rozhodnutí
acme.blankLinesBeforeReturn. Instanci nároku si pravidlo připraví jednou v metodě configure() a
v uzávěře ji pouze vrací – nevytváří ji tedy zbytečně u každé mezery znovu. Pravidlo neprochází žádné uzly,
protože GapRule procházení stromu vůbec nepoužívá; veškerou práci odvede jádro na základě sesbíraných
nároků ve fázi Formatting. Právě proto musí atribut RuleInfo u pravidel pro bílé znaky uvádět
výhradně Stage::Formatting (jinou fázi jádro odmítne jako chybu konfigurace). Při nastavení hodnoty
1 zní vygenerované hlášení Expected 1 blank line before the return, 0 found. – větu automaticky
poskládá samotné jádro podle kontextu a ve výpisu k ní připojí cestu acme.blankLinesBeforeReturn. Stejným
mechanismem vznikají hlášky jako Expected a single space before the `=` operator. nebo
Expected a line break before the opening brace.. Autor pravidla texty chybových hlášení vůbec nepíše.
Uzávěra obdrží v objektu Gap hraniční token mezery, hodnotu slotu či položku seznamu, její pořadový
index a celkový styl souboru včetně maximální délky řádku. Na základě těchto dat se pak může dynamicky
rozhodnout – například vynechat tečku pro spojování řetězců a uplatnit nárok jen na ostatní binární operátory:
public function getClaims(): array
{
$operator = fn(Gap $gap): ?Claim => $gap->token->text === '.' ? null : $this->claim;
return [
BinaryOpNode::class => ['operator' => [$operator, $operator]],
AssignmentNode::class => ['equals' => [$this->claim, $this->claim]],
];
}
Pomocí metody findAnalysis() si uzávěra může vyžádat také analýzu souboru, naprosto stejně jako přes RuleContext::findAnalysis(). Takto se například
pravidlo zalamující dlouhé řádky dotazuje analýzy Analyses\IndentationSnapshot (snímku odsazení z počátku
průchodu), zda má daný řádek už finální odsazení; dokud jej nemá, pravidlo o zalomení nerozhoduje.
Pravidlo s jediným požadavkem hlásí nálezy automaticky pod ním. Pravidlo obsluhující více požadavků určí
u každého nároku příslušné rozhodnutí: buď v konstruktoru
new Claim(Space::Single, decision: 'acme.operatorSpacing'), nebo nad existující instancí voláním
$claim->withDecision('acme.operatorSpacing'). Prázdné řádky pod komentářem mohou spadat pod samostatné
rozhodnutí předané v parametru decisionBelowComment.
Rozhodnutí, které musí být pokaždé stejné
Nárok závislý na celkovém tvaru konstrukce (např. zda je seznam již rozložený na více řádků nebo zda řádek přesahuje povolenou délku) musí vrátit pro každou mezeru téže konstrukce naprosto stejnou odpověď – bez ohledu na to, jak jádro mezitím upravilo předchozí mezery. Jinak by první čárka seznam zalomila a druhá by jej zase slepila dohromady. Proto se takové vyhodnocení provádí pro celý uzel pouze jednou:
public function getClaims(): array
{
return [
ArgumentListNode::class => [
'items:item' => [
function (Gap $gap): ?Claim {
$list = $gap->value->parent->parent; // the ArgumentListNode
return $gap->once($list, fn() => $this->isBroken($list)) ? Claim::nextLine() : null;
},
null,
],
],
];
}
Metoda once() vyhodnotí předanou funkci u první mezery daného uzlu, výsledek si uloží a u všech
zbývajících mezer téhož uzlu pak až do konce průchodu vrací tutéž uloženou odpověď. Pomocnou metodu
isBroken() si napíšete sami; seznam v ní považujte za víceřádkový ve chvíli, kdy kterákoliv položka nebo
zavírací závorka začíná na novém řádku, aby se pravidlo uplatnilo konzistentně na celý zbytek seznamu. Nad voláním
foo($a, $b, s argumentem $c) na dalším řádku tak pravidlo odřádkuje každý argument zvlášť a
nahlásí jediné souhrnné porušení Expected a line break before the argument., zatímco jednořádkové
bar($a, $b) ponechá beze změny.
Rozhodnutí přijaté přes once(), jehož výsledkem není null, false ani prázdné
pole, se současně vykazuje jako jediné porušení pro celou konstrukci, nikoliv jako samostatná chyba pro každou mezeru:
špatně je totiž celkový tvar konstrukce, ne každý její dílčí zlom. Podmínka roztažená na pěti řádcích tak
vygeneruje jediný nález umístěný na pozici prvního problému – na tomto řádku jej proto dokáže umlčet jediný
komentář dresscode:ignore a baseline si jej pamatuje pod jedním společným otiskem. Samotné opravy v kódu
přitom proběhnou na všech místech stejně, jako kdyby se každá mezera hlásila zvlášť.
Kdo vyhraje
Jádro skládá nároky z obou stran mezery složku po složce podle pevných architektonických pravidel, nikoliv podle pořadí v konfiguraci:
- nárok vázaný na konkrétní třídu uzlu má přednost před obecným nárokem
*, - nárok vnitřního slotu má přednost před nárokem nadřazeného předka,
- u vodorovné mezery vítězí přísnější požadavek nad volnějším – u příkazu
return;tak vyhrajeExpected no whitespacenadExpected at least one space, - u zalomení řádku má
Line::Nextvždy přednost předLine::Same, - u počtu prázdných řádků se uplatní průnik obou rozsahů, a pokud jsou rozsahy neslučitelné, zvítězí ten užší z nich.
Dva pevné nároky na tutéž složku téže strany stejného slotu vyvolají výjimku ConfigurationException
okamžitě při inicializaci pravidel. Dvě uzávěry (closures) se o jeden slot podělit smějí, protože každá z nich
může v situaci, kterou řeší ta druhá, vrátit null. Pokud by však u jedné konkrétní mezery vrátily
nárok na tutéž složku obě současně, vyhodí jádro ConfigurationException za běhu přesně u této mezery
(Rules `Acme\CodeStyle\ARule` and `Acme\CodeStyle\BRule` both govern the whitespace before `PhpSyntax\Nodes\Expression\BinaryOpNode.operator`.).
Vestavěná pravidla jsou navržena tak, aby si nikdy nepřekážela; pokud chce externí plugin řešit některou mezeru jinak
než jádro, musí projekt příslušné rozhodnutí jádra nastavit na keep.
Vynucené zalomení řádku provede jádro okamžitě a přesný počet prázdných řádků doladí v navazujícím
průchodu. Zakázané zalomení řádku naopak odstraní pouze tehdy, pokud mezera neobsahuje žádné komentáře, ale čistě
jen bílé znaky. Samotné odsazení nově vzniklého řádku není věcí nároku: jádro mu při zalomení nastaví výchozí
odsazení a pravidlo indentation.unit jej v závěrečné fázi umístí na přesnou úroveň.
Testování pravidla pro bílé znaky
Pravidlo pro bílé znaky se testuje naprosto stejně jako každé jiné pravidlo – pomocí třídy RuleTester
nad fixturami. Kontrolované chybové hlášky v souboru
.violations jsou ty, které automaticky poskládalo jádro.
Co GapRule z principu neumí, je sahat na samotné tokeny: skládá se výhradně z nároků na mezery a nic
jiného nedělá. Pokud jedna jazyková konstrukce vyžaduje jak formátování mezer, tak modifikaci tokenů, vznikne dvojice
pravidel (každé s vlastním rozhodnutím): jedno GapRule a jedno NodeRule. Přesně takto jsou řešeny například atributy:
spacing.attribute si nárokuje mezery uvnitř #[...], zatímco
classes.emptyParentheses.attribute přidává či maže prázdné závorky za názvem atributu. Může se to zdát
jako zbytečné dělení – ale jen do chvíle, než v projektu potřebujete jedno z těchto dvou chování vypnout pomocí
keep.