Procházení stromu
Hledání uzlů podle třídy, Traverser s řízením průchodu, cesta nahoru k rodičům a do
stran po tokenech, pozice ve zdrojáku a otázky, na které strom odpoví za vás.
Hledání
Nejkratší cesta k uzlům dané třídy vede přes find() a findFirst():
foreach ($file->find(FunctionCallNode::class) as $call) {
// ...
}
$class = $file->findFirst(ClassNode::class);
Když třída sama nestačí, přidá se za ni podmínka. Třída zůstává na svém místě, takže podmínka i výsledek jsou typované a IDE i PHPStan vědí, s čím pracují:
$long = $file->find(StringNode::class, fn(StringNode $s) => strlen($s->value) > 80);
Filtrem smí být i rozhraní, takže find(ClassLikeNode::class) najde naráz třídy, rozhraní, traity
i výčty. Kdo chce úplně všechno a rozhodnout se až v podmínce, napíše find(Node::class, $condition).
find() vrací pole, tedy snímek pořízený předem, takže je bezpečné strom během procházení měnit.
Prochází uzly, ne tokeny; k tokenu se dostanete přes uzel, ve kterém sedí, přes getTokens() celého podstromu,
nebo procházením od getFirstToken() metodou getNext().
Traverser
Když potřebujete vidět všechno v pořadí zdrojáku, včetně tokenů, nebo reagovat na vstup i výstup z uzlu, je na to
Traverser:
use PhpSyntax\Traverser;
(new Traverser)->traverse($file, function (Node|Token $node) {
// enter
}, function (Node|Token $node) {
// leave
});
Callback smí uzel, který dostal, nahradit nebo odstranit; traverser pak do něj ani do jeho náhrady nesestoupí, sourozence odpojené mezitím přeskočí a uzly vložené mezitím nechá dalšímu průchodu. Díky tomu se dá měnit strom přímo během průchodu bez zvláštního režimu.
Každý enter má svůj leave, a to se stejným objektem, i když uzel mezitím ze stromu
zmizel. Callback, který si drží hloubku nebo zásobník, tak nemusí řešit chybějící patro. Platí to i pro
StopTraversal: do dalších uzlů už průchod nevstoupí, ale ty, do kterých vstoupil, cestou ven opustí, ten
zastavující včetně. Pozor na to, že leave může dostat uzel, který ve stromu už není. Pozná se podle
$node->parent === null (kromě kořene, ten rodiče nemá nikdy) a replaceWith() na něm vyhodí
výjimku, takže kdo v leave mění strom, ať se na rodiče nejdřív podívá.
Callback může průchod řídit návratovou hodnotou:
(new Traverser)->traverse($file, function ($node) {
if ($node instanceof ClosureNode) {
return Traverser::DontTraverseChildren; // do tělíčka closure nechceme
} elseif ($node instanceof HaltCompilerNode) {
return Traverser::StopTraversal; // dál už nic zajímavého není
}
});
Cokoli jiného, co callback vrátí, průchod ignoruje, takže se nemusíte hlídat, co z něj vypadne.
Nahoru a do stran
Každý uzel i token zná svého rodiče ($node->parent) a od něj se dá jít výš:
$method = $call->findAncestor(MethodNode::class); // nejbližší předek dané třídy, nebo null
$class = $call->findAncestor(ClassLikeNode::class);
Po tokenech se chodí do stran: $token->getNext() a getPrevious() je následující a předchozí
token v souboru bez ohledu na strukturu. $node->getFirstToken() a getLastToken() jsou okraje uzlu,
takže „token před uzlem“ je $node->getFirstToken()->getPrevious().
Text uzlu je vlastnost $node->text: všechno od prvního tokenu po poslední, bez trivia na okrajích. Hodí se
do hlášek i při ladění a je to totéž, co $token->text, jen o patro výš.
Pozice
$token->getLine(); // řádek v aktuálním stavu stromu
$token->getColumn(); // sloupec
$token->getOffset(); // bajtový offset
$token->originalLine; // řádek v původním souboru, nikdy se nemění
$node->getStartLine(); // řádky uzlu
$node->getEndLine();
Rozdíl mezi getLine() a originalLine je podstatný, jakmile strom měníte: první sleduje změny,
druhé je pozice, kterou zná uživatel ze svého souboru, a právě ta patří do hlášení. U uzlu, který vznikl až za
běhu, je originalLine rovno null. Pozice počítá index tokenů, který se staví líně a po změně
stromu se opravuje jen tam, kde je to potřeba, takže dotaz hned po změně stojí tolik, jak daleko od té změny leží.
Otázky, které si klade každý nástroj
Pár otázek si nad kódem položí každý, kdo ho upravuje, a odpovědět na ně pořádně dá práci na dva dny. Strom je proto zodpoví za vás.
$a->matches($b); // jsou ty dva uzly stejné, token po tokenu, bez ohledu na mezery?
$expr->isRepeatableRead(); // dá se výraz vyhodnotit podruhé, aniž by se něco změnilo?
$expr->getAccessKind(); // jak na něj rodič sahá: přes ->, [], (...) nebo ::?
$expr->isDereferenced(); // táž otázka bez druhu: sahá na něj rodič vůbec?
$expr->isDereferenceable(); // a naopak: šlo by na jeho člen sáhnout i bez závorek?
$expr->canNameClass(); // smí stát tam, kde se jmenuje třída?
$expr->isWritable(); // smí stát tam, kam se přiřazuje?
$paren->isRedundant(); // můžou tyhle závorky pryč?
$expr->hasValue(); // je to zapsaná hodnota, ne výpočet?
$expr->toValue(); // a jaká
matches() porovnává texty tokenů, ne trivia, takže $a->b a $a -> b si
odpovídají. isRepeatableRead() říká true pro proměnné, přístupy k vlastnostem a prvkům,
konstanty a literály, a false pro volání, new, přiřazení, ++ a cokoli, co může mít
vedlejší účinek. Dohromady odpovídají na otázku „opakuje tenhle ternár svou podmínku a smím ji vyhodnotit jen
jednou“. Odpověď je ale syntaktická: co jazyk za takovým čtením spustí sám, na to strom nevidí, ať jde o magický
getter nebo hook za ->, ArrayAccess za [], nebo __toString() za
interpolovaným řetězcem. Kdo si tohle dovolit nemůže, potřebuje typy, a ty syntaxe nezná.
isRedundant() na uzlu ParenthesizedNode je z těch otázek nejpracnější a odpovídá se na ni
nejčastěji špatně: záleží na prioritě a asociativitě operátoru uvnitř i vně závorek, na tom, jestli se na výsledek
přistupuje jako na objekt nebo pole, a na několika zvláštních případech. Každý uzel zapsaný operátorem proto
implementuje rozhraní OperatorNode s metodou getPrecedence(), která vrátí sílu vazby a stranu, ke
které se operátor kloní; isRedundant() z toho složí odpověď. Kde si jistá není, odpoví
false.
Vedle priority nese uzel ještě jednu vlastnost. Operátor, který nemá levý operand a jehož pravý sahá, kam mu kód
dovolí, se hlásí rozhraním RightExtendingNode: šipková funkce, throw, print,
yield, yield from a include. Před sebou nemůže pohltit nic, takže všude, kde za
závorkami už nic nestojí, jsou zbytečné bez ohledu na to, jak volně ten výraz váže. $f = (fn() => 1) je
tedy totéž co $f = fn() => 1, kdežto (fn() => 1) + 2 závorky potřebuje, protože bez nich by
tělo funkce pohltilo i tu dvojku.
Prioritou to ale nekončí. Ukázkový případ, na kterém se ruční implementace spálí: :: bere nalevo
jméno třídy, takže FOO::class je řetězec 'FOO', kdežto (FOO)::class je
třída objektu, který ta konstanta drží. Ty závorky tedy nejsou zbytečné, teprve ony z jména dělají výraz. Totéž
platí pro (FOO)::BAR, (FOO)::m(), (FOO)::$p a pro true a null,
která se také píšou jako jména. Cokoli jiného nalevo od :: je výraz tak jako tak, a -> ani
[] si na jméno nedělají nárok, takže (FOO)->x a (FOO)[0] závorky pustí.
Právě proto se isDereferenceable() ptá, co za tím výrazem stojí, a bere AccessKind:
Member pro -> a [], Call pro (...) a ClassName
pro ::. Odpověď se totiž případ od případu liší, jak je vidět na FOO: FOO->x
projde, FOO() je volání úplně jiné věci a FOO::class je jméno třídy.
Tentýž AccessKind vrací getAccessKind(), jen z opačné strany: řekne, kterým z těch tří
způsobů na výraz sahá jeho rodič, a null, když na něj nesahá vůbec. isDereferenced() je táž
otázka položená bez druhu.
isWritable() je otázka z druhé strany: smí ten výraz stát nalevo od =? Proměnná, prvek pole,
statická vlastnost a vlastnost přes -> ano, přes ?-> ne, protože ta se jen čte.
A nejde jen o poslední krok: stačí jediné ?-> kdekoli v řetězci, kterým zápis prochází, a odpověď
je false, protože i $o?->p->q = 1 je pro PHP chyba. Počítá se i volání metody v řetězci,
takže $o?->m()->p zapisovatelné není, a stejně dopadne řetězec, který začíná hodnotou bez místa pro
zápis: literálem, konstantou, new nebo clone. (new Foo)->bar tedy zapisovatelné
není, foo()->bar a Foo::create()->bar ano. Co stojí vedle toho řetězce, třeba
$a[$b?->c], se jen čte a na zapisovatelnost vliv nemá. Destrukturace se na to neptá vůbec:
ListNode není výraz, protože stojí jedině tam, kam se přiřazuje, a hodnotu vlastní nemá. Sloty, které cíl
přiřazení drží, to říkají typem ExpressionNode|ListNode, takže kdo se nad nimi ptá na výraz, musí si typ
nejdřív zúžit.
$paren = $expr->findFirst(ParenthesizedNode::class);
if ($paren->isRedundant()) {
$paren->replaceWith($paren->expression); // ($a * $b) + $c → $a * $b + $c
}
hasValue() a toValue() odpovídají na „co je tu napsáno za hodnotu“: skaláry,
null, true, false, pole i s vnořením a rozbalením, unární plus a minus u čísla,
závorky a heredoc bez interpolace. Jméno konstanty hodnota není, ani PHP_INT_MAX, ani
self::FOO: co znamená, závisí na kódu okolo, a to už syntaxe neví. toValue() nad výrazem, který
hodnotu nemá, vyhodí LogicException, protože null je platná hodnota a musí jít odlišit od
„žádná“.