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. Jedinou výjimkou je StopTraversal, který průchod ukončí a nedokončené leave už nezavolá. Pozor na to, že leave může dostat uzel, který ve stromu už není: replaceWith() na něm vyhodí výjimku, takže kdo v leave mění strom, ať se nejdřív podívá na $node->parent.

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->isDereferenced();    // sahá rodič na jeho člen nebo prvek?
$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“.

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.

$paren = $expr->findFirst(ParenthesizedNode::class);
if ($paren->isRedundant()) {
	$paren->replaceWith($paren->expr);   // ($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á“.

verze: 1.0