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á“.