Úpravy stromu

Zápis do slotu, nahrazení a odstranění uzlu, práce se seznamy a vkládání nově naparsovaného kódu: jak strom měnit tak, aby zůstal konzistentní a aby se po vytištění změnilo jen to, na co jste sáhli.

Zápis do slotu

Slot je vlastnost s property hookem, takže se do něj zapisuje obyčejným přiřazením:

$if->condition = $parser->parseExpression('$order->isPaid()');
$node->replaceChild($old, $new);
$node->setSlot('condition', $expr); // když jméno slotu znáte až za běhu

Hook se postará o to, co byste jinak museli hlídat sami: odmítne hodnotu, která už má rodiče, novou adoptuje, starou pustí a ohlásí změnu indexu tokenů, který drží pořadí, řádky a sloupce. Hodnotu, která do slotu nepatří vůbec, odmítne ještě dřív typ vlastnosti, a jméno, které žádným slotem není, odmítne setSlot().

Cokoli zápis odmítne, odmítne dřív, než se ve stromu cokoli pohne. Zápis více hodnot najednou, třeba položky i s oddělovačem, ověří všechny předem a odmítne i oddělovač vytažený z vkládané položky, protože by stál ve dvou seznamech zároveň. Po výjimce tedy vypadá přesně jako předtím cílový strom i fragment, ze kterého hodnota přišla, včetně trivia okolo, takže se z neúspěšné úpravy nemusíte zotavovat.

Uzel patří právě jednomu stromu, takže vložit uzel, který už rodiče má, skončí výjimkou; clone dá hlubokou kopii bez rodiče, kterou vložit lze. Stejně dopadne pokus zapsat uzel sám do sebe nebo do něčeho, co drží, protože by vznikl cyklus.

Ze zákazu jsou ale dvě výjimky a ušetří spoustu klonování. Ta první: zapisovaná hodnota smí mít rodiče, pokud stojí uvnitř té hodnoty, kterou zápis zrovna uvolňuje. Takový podstrom ze stromu odchází tak jako tak, takže si z něj kus vyzvednout smíte:

$paren->replaceWith($paren->expression); // závorky pryč, výraz zůstane; jestli smí, řekne isRedundant()
$assign->expression = $binary->right;    // z výrazu si nechám jen pravou stranu

A ta druhá: hodnota smí mít rodiče i tehdy, když celý strom, ve kterém stojí, nemá kořen v FileNode. To je případ naparsovaného fragmentu a taky uzlu, který jste ze souboru už vyřadili. Není tam žádný index, který by se mohl rozejít, takže si z takového podstromu můžete brát:

$call->replaceWith($replacement);      // staré volání je teď mimo soubor
$replacement->object = $call->object;  // a tak si z něj vezmu, co potřebuju

Co z takového podstromu zbude, je odpad, ne materiál: pořád ukazuje na uzel, který jste si vzali, takže ho už nikam nevkládejte. Kdyby se přesto do souboru dostal, ohlásí to index při prvním dotazu na pozici nebo sousední token výjimkou <Class> stands in the file twice: a write took it out of a subtree that entered the file afterwards; a clone keeps it in both places. Tisk se indexu neptá, takže takový soubor vytiskne i s uzlem dvakrát. Výjimečně přijde výjimka až po zápisu: replaceWith() se po výměně ptá na sousední tokeny kvůli švům, a když vkládá takový odpad, vyhodí ji ona, jenže to už je strom změněný.

Vyzvednutý uzel si s sebou nese trivia z místa, odkud přišel, úplně stejně jako klon. Když ho vkládáte jinam, vyčistěte mu okraje: $node->setEdgeTrivia([], []). Kopii cizího uzlu pro nové místo rovnou s prázdnými okraji dá $node->withoutEdgeTrivia().

Co nemá hook, hlídá jazyk: text a trivia tokenu jsou private(set) a mění se jeho metodami (Trivia), položky seznamu jsou protected(set) a mění se metodami seznamu, a parent si zapisuje strom sám. Rozbít strom zápisem mimo API tedy nejde. Typ položky seznamu ale PHP nekontroluje, ten je jen v phpDocu: append() přijme jakýkoli uzel, takže do argumentů volání patří ArgumentNode, ne holý výraz, a hlídá to až PHPStan.

Nahradit a odstranit

$node->replaceWith($other);
$node->remove();
$node->remove(CommentPolicy::Drop);

Mezi přiřazením do slotu a replaceWith() je rozdíl, na který se přijde až v diffu: replaceWith() zachová trivia kolem starého uzlu, přiřazení ne.

$ternary->condition = $parser->parseExpression('$a !== []');
// $x = $a !== []? 'yes' : 'no';

$ternary->condition->replaceWith($parser->parseExpression('$a !== []'));
// $x = $a !== [] ? 'yes' : 'no';

Fragment z parseru má okraje prázdné, takže při přiřazení se ztratí mezera, která patřila starému uzlu. Přiřazení proto použijte tam, kde slot plníte poprvé nebo kde si trivia řešíte sami; všude jinde sáhněte po replaceWith().

replaceWith() navíc hlídá švy. Když nový uzel skončí těsně u tokenu, se kterým by ho lexer přečetl jako jednu věc, vloží mezi ně mezeru:

// echo 'a'.$n;
$var->replaceWith($parser->parseExpression('119'));
// echo 'a'. 119;     bez té mezery by z '.' a '119' bylo číslo .119

Ptá se na to metodou Lexer::canAdjoin($left, $right), tedy jestli se dva texty tokenů smí napsat těsně vedle sebe. Zeptejte se jí i vy pokaždé, když mezeru mezi tokeny naopak ubíráte: z - -$a se bez ní stane --$a, tedy dekrementace. Zápis do slotu přiřazením se na švy neptá, protože tam si trivia řešíte sami.

remove() odstraní položku seznamu i s jejím oddělovačem a s tím, co na něm visí: uzel, který stál na řádcích sám, vezme řádky s sebou, jinak zůstane okolní mezera a po čárce nezbude mezera navíc. Komentáře uvnitř odstraňovaného uzlu se podle CommentPolicy přesunou k následujícímu tokenu (výchozí), k předchozímu, nebo zahodí; ztratit komentář mlčky nejde. Komentář, který stál na začátku řádku, si bere i odsazení, takže po Drop nezůstane řádek s pouhým tabulátorem.

Výraz místo výrazu

Výraz vkládaný mezi operátory je zvláštní případ, protože operátory mají priority a replaceWith() zapíše, co dostane, doslova. Odtud pochází celá třída tichých chyb ve významu:

// $s = ucfirst($a ?? $b) . 'x';   a z toho volání chceme jen jeho argument
$call->replaceWith($operand);
// $s = $a ?? $b . 'x';            což PHP čte jako $a ?? ($b . 'x')

ExpressionNode::replaceWithExpression() udělá totéž, ale závorky kolem nového výrazu nechá všude, kde je isRedundant() za zbytečné neprohlásí:

$call->replaceWithExpression($operand);
// $s = ($a ?? $b) . 'x';

Pravidlo, podle kterého mezi nimi volit: replaceWith() tam, kde je místo ohraničené oddělovači, tedy argument volání, větev matche nebo operand returnu. replaceWithExpression() všude, kde je to místo operandem operátoru nebo se do něj sahá přes ->, [], () a ::.

Nejvíc to bolí u šablon operátorů, protože špatný výsledek je pořád platné PHP a nikdo si ho nevšimne. Dosadit $a ?? $b do 0 |> ucfirst(...) obyčejným replaceWith() dá $a ?? $b |> ucfirst(...), a to PHP čte jako $a ?? ($b |> ucfirst(...)).

Odpověď se odvozuje z místa, kde uzel stojí, takže replaceWithExpression() funguje i v odpojeném fragmentu. Vždycky je to ale odpověď o tom jednom místě.

Seznamy

$stmts->append($stmt);
$stmts->insert($index, $stmt);
$stmts->removeItem($stmt);

$args = $call->arguments->items;      // SeparatedNodeList, ne ArgumentListNode se závorkami
$arg = $parser->parseFragment(ArgumentNode::class, '$c');
$args->append($arg);                  // oddělovač se odvodí z existujících, nebo ', '
$args->insert(0, $parser->parseFragment(ArgumentNode::class, '$first'));
$args->setTrailingSeparator(null);    // pryč s koncovou čárkou
$args->setTrailingSeparator(new Token(ord(','), ','));   // a zase zpátky

SeparatedNodeList si oddělovače hlídá sám: při vložení odvodí chybějící čárku z těch, které v seznamu jsou (nebo použije , ` u jednořádkového seznamu), a ve víceřádkovém seznamu dá položce odsazení souseda; oddělovač s komentářem ale vzorem není, protože komentář je obsah a kopie by ho zdvojila, takže se formát vezme z dalšího oddělovače. Vzorem není ani koncová čárka jednořádkového seznamu, protože stojí těsně u závorky. Oddělovač, který se vkládá s položkou, je ten, který k ní patří: ten za ní, a u poslední položky ten před ní. Komentář za předchozí položkou tak zůstane u ní. Při odstranění platí totéž, s položkou odejde čárka za ní, u poslední ta před ní. Koncový oddělovač nastaví nebo odebere `setTrailingSeparator(); prázdný seznam ho nepřijme.

removeItem() a remove() se liší v tom, co udělají s okolím. removeItem() vyjme položku i s jejími trivia a oddělovačem a víc nic neuklízí, takže s ní odejde i komentář na jejím řádku. remove() na uzlu vezme s sebou celé řádky, na kterých uzel stál sám, a komentáře přesune podle CommentPolicy, jak je popsáno výše. Když nevíte, sáhněte po remove().

NodeList příkazů, kde žádný oddělovač není, se chová stejně: pokud seznam stojí v souboru, dá položce bez vlastních trivia odsazení souseda a konec řádku, jaký má soused; v odpojeném fragmentu ji vloží, jak je. Vložení příkazu je tedy jeden řádek a nic se kolem nespravuje:

$stmt = $parser->parseStatement('$this->log("total");');
$return->parent->insert($return->parent->indexOf($return), $stmt);

Prázdný řádek, který v seznamu byl (třeba za importy), zůstane, kde byl. Položka, která končí řádek svým vlastním textem, tedy zavírací tag nebo inline HTML, nedostane nic, protože si konec řádku nese sama.

Nový uzel

Nový kód se nestaví z tokenů, parsuje se z řetězce: parseExpression(), parseStatement(), parseType(), parseName() a obecné parseFragment() vrátí odpojený uzel s prázdnými trivia na okrajích, připravený k vložení. Samotné jméno vyrobí i NameNode::fromText() a IdentifierNode::fromText(), řetězec StringNode::fromValue(). Konstruktory uzlů jsou @internal: jejich parametry kopírují gramatiku a další verze PHP je může rozšířit, takže na ně nespoléhejte.

Řetězec ale neunese uzly, které už máte v ruce. Pro to, co se skládá z nich, jsou tovární metody of(): volání (FunctionCallNode, MethodCallNode, StaticMethodCallNode, NewNode), jednořádkový seznam argumentů (ArgumentListNode), binární operace (BinaryOpNode), složené přiřazení $a += $b (CombinedAssignmentNode) a závorky (ParenthesizedNode).

// trim($name)  →  htmlspecialchars(trim($name))
$call->replaceWith(FunctionCallNode::of(
	NameNode::fromText('htmlspecialchars'),
	ArgumentListNode::of($call->withoutEdgeTrivia()),
));

Továrna vyrobí tokeny operátorů a oddělovačů sama, uzlům, které dostane, vyčistí okraje a adoptuje si je; posílejte jí proto kopii, ne uzel ze živého stromu. Takový uzel továrna odmítne výjimkou dřív, než na něm cokoli změní. A sama dá do závorek to, do čeho by se jinak sáhnout nedalo, takže MethodCallNode::of($parser->parseExpression('new Foo'), 'bar') napíše (new Foo)->bar(). Totéž dělá operace se svými operandy: BinaryOpNode::of($a, '.', $c), kde $a je výraz $x ?? $y, napíše ($x ?? $y) . $c. Výsledkem je přesně ten strom, jaký by parser vrátil pro týž text.

MethodCallNode::of() bere ještě nullsafe: true a pak napíše ?->. BinaryOpNode::of() přijme jako operátor jen text binárního operátoru a cokoli jiného odmítne výjimkou InvalidArgumentException, také instanceof, které má vlastní uzel InstanceofNode. Tentýž uzel zadaný dvakrát, nebo uzel stojící uvnitř jiného zadaného, odmítne každá továrna, protože by ho musela vzít z místa, kam ho právě dala.

Závorky, které chcete v kódu mít kvůli čtenáři, napíšete ParenthesizedNode::of($expr). Ty, které tam mají být kvůli prioritě, řeší replaceWithExpression() za vás.

Kopii existujícího uzlu pro jiné místo berte metodou withoutEdgeTrivia(), která dá hlubokou kopii bez rodiče a s prázdnými okraji. Prostý clone i vyzvednutý uzel si totiž nesou úvodní trivia z místa, odkud přišly, a u prvního příkazu souboru je mezi nimi i <?php, které by se ve výstupu objevilo dvakrát.

Nejmenší možná úprava bývá změna textu jednoho tokenu a často stačí:

$call->name->text = 'count';                  // jméno; hook ho přetokenizuje
$string->setValue('Hello', "'");              // hodnotu literálu i s uvozovkou
$token->setText('!==');

Modifikátory nejsou sloty, ale tokeny v ModifiersNode, a mění se jeho metodami. Samostatný token se vyrobí konstruktorem z druhu a textu:

$modifiers = $class->modifiers;
if (!$modifiers->has(TokenKind::Final)) {
	$modifiers->append(new Token(TokenKind::Final, 'final'));
}

if ($readonly = $modifiers->findToken(TokenKind::Readonly)) {
	$modifiers->removeToken($readonly);
}

O mezery a řádky se starat nemusíte. První modifikátor deklaraci otevírá, a proto převezme úvodní trivia tokenu, před který se postaví, tedy odsazení, dokumentační komentář a u první třídy souboru i <?php; token bez koncových trivia dostane za sebe mezeru. Odebraný modifikátor předá svá úvodní trivia tokenu za sebou a komentář za ním zůstane v kódu.

Posun odsazení

Když se konstrukce stěhuje o patro hlouběji nebo mělčeji, nestačí přepsat odsazení prvního řádku:

Indentation::shift($method, 1, $style);   // celá metoda o úroveň doprava

shift() posune každý řádek, který uzel otevírá, a s tělem heredocu i jeho uzavírací návěští, takže hodnota heredocu zůstane stejná (PHP od ní odsazení návěští odečítá). Inline HTML a obsahu řetězců se nedotkne.

Co si strom hlídá sám a co ne

Sám si hlídá rodiče, index a FileNode::$revision, které roste s každou změnou; kdo potřebuje vědět, jestli se něco změnilo, porovná revizi před a po, ale nepočítá s tím, že jedna úprava je jedno zvýšení, protože složená úprava jako remove() jich udělá několik.

Nehlídá si dvě věci, které zůstávají na vás:

  • Kanonická trivia. Konec řádku, který uzavírá řádek tokenu, patří do jeho koncových trivia, ne do úvodních trivia dalšího tokenu. ensureLeadingNewline() a setBlankLinesBefore() to dělají správně; kdo skládá trivia ručně, ať to dodrží, jinak getTrailingSpace() zalomení neuvidí.
  • Význam. Strom vám dovolí nahradit podmínku čímkoli a odstranit return; jestli to smíte, je otázka na isRepeatableRead(), matches() a na vás.

Po úpravě se tiskne Printer::print($file) nebo (string) $file a výstup je původní soubor se změnami přesně tam, kde jste je udělali.