Úpravy stromu
Zápis do slotu, nahrazení a odstranění uzlu, výraz mezi operátory i v řetězci, práce se seznamy, vkládání nového kódu, tokeny a typy: 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 = $builder->expression('$order->isPaid()');
$node->replaceChild($old, $new);
Nový kód tady i v dalších ukázkách vyrábí $builder, instance PhpSyntax\Builder; jak,
říká stránka Nové uzly.
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.
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([], []).
Co nemá hook, hlídá většinou jazyk: trivia tokenu jsou private(set) a mění se jeho metodami (Trivia), položky seznamu jsou vlastním úložištěm seznamu, čtou
se indexem, foreach nebo getItems() a mění se jen metodami seznamu, a parent uzlu je
protected(set), takže si ho zapisuje strom sám. Dvě výjimky jazyk nehlídá: text tokenu je veřejná vlastnost
zděděná z PhpToken a mění se jen metodou setText(), a parent tokenu je veřejný, aby
ho uzel zapsal bez volání metody, ale píše ho jen strom. 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);
$node->remove(mergeBlankLines: true);
$block->unwrap();
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 = $builder->expression('$a !== []');
// $x = $a !== []? 'yes' : 'no';
$ternary->condition->replaceWith($builder->expression('$a !== []'));
// $x = $a !== [] ? 'yes' : 'no';
Uzel z builderu 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().
Nahradíte-li uzel uzlem bez tokenů, třeba příkazy souboru prázdným seznamem, trivia kolem starého uzlu nezmizí.
Dostanou se k tokenům po obou stranách tak, jak by je tam dal lexer: předchozí token dostane, co patří do konce jeho
řádku, následující všechno ostatní, včetně <?php. Kde po žádné straně žádný token nestojí a
trivia by neměla kam jít, replaceWith() výjimkou odmítne dřív, než cokoli zapíše.
Zápis toho, co už na místě stojí, nic nedělá. Stejný text, stejná trivia, stejné jméno nebo operátor nezmění strom, takže obecná transformace, která uzel jen vrátí, nic nezneplatní.
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($builder->expression('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.
Prázdné řádky nad odstraněným uzlem a pod ním ve výchozím stavu zůstanou a sečtou se v jeden rozestup. Když má
zůstat rozestup, jaký byl, předejte mergeBlankLines: true:
class A
{
public $a;
public $b;
// c
public $c;
}
Odstraněním public $b; zůstanou mezi public $a; a komentářem tři prázdné řádky, s
mergeBlankLines jen jeden. Ze dvou rozestupů, mezi kterými uzel stál, zůstane ten užší, počítaný nad
komentáři, které uzel nechal dalšímu tokenu. U první a poslední položky seznamu zůstane rozestup směrem k okraji, tedy
ten za otevírací závorkou nebo <?php, nebo ten před zavírací závorkou, takže odebrání členu nezmění,
jak třída začíná a končí. Prázdné řádky mezi komentářem nad odstraněným uzlem a uzlem samým zůstanou pod
komentářem, takže komentář oddělený od uzlu prázdným řádkem zůstane stejně oddělený od toho, co následuje; pod
poslední položkou jich ale zůstane nanejvýš tolik, kolik má sloučený rozestup. Slučuje se jen tam, kde uzel i token za
ním začínají řádek: uzel, který sdílí řádek s jiným, vlastní rozestupy nemá. Konec řádku se zapíše takový,
jaký mají nejbližší trivia, takže soubor s CRLF zůstane CRLF.
BlockNode::unwrap() vyjme příkazy z bloku, který stojí mezi příkazy, a postaví je na jeho místo.
Složené závorky odstraní stejně jako remove(), a komentář, který na nich visel, přejde k dalšímu tokenu.
Z tohohle kódu:
$a;
{ // start
$b;
$c;
}
$d;
udělá $block->unwrap() tenhle:
$a;
// start
$b;
$c;
$d;
Příkazy si ponechají svá trivia i s odsazením; o úroveň zpátky je posune Indentation::shift(). Blok, který je tělem řídicí struktury,
unwrap() odmítne výjimkou, protože tělo se mění zápisem do slotu. Odmítne i blok, jehož závorka sousedí
se zavíracím tagem ?> nebo s inline HTML: co stojí za nimi, je výstup skriptu, a mezera, kterou by po sobě
závorky nechaly, by se vypsala.
Položku importu remove() odebere i s celým příkazem use, když je v něm jediná, viz Importy.
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 přiřazení do slotu i
replaceWith() zapíšou, co dostanou, doslova. Strom pak drží, co jste mu dali, jenže po vytištění a novém
načtení by dopadl jinak: $binary->right = $builder->expression('$c + $d') pod * vytiskne
$a * $c + $d, a to PHP čte jako součet se součinem vlevo. Že se výraz po vytištění naparsuje zpátky do
stejného stromu, slibuje jen replaceWithExpression(). 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ě. Místo, jehož slot má užší typ a závorky nepřijme, dostane
výraz bez nich.
Jméno členu nebo proměnné zapsané holou proměnnou, tedy $m v $o->$m, $$m nebo
A::$m(), vezme proměnnou zase holou a jakýkoli jiný výraz ve složených závorkách, protože jen tak ho tam
gramatika přečte jako výraz:
// $o->$m;
$fetch->name->replaceWithExpression($builder->expression('$a . "b"'));
// $o->{$a . "b"};
Co metoda odmítne, odmítne dřív, než cokoli změní. Zeptat se předem, aniž by se cokoli pohnulo, jde metodou
checkReplaceWithExpression(): je to suchý běh replaceWithExpression(), který vyhodí přesně tu
výjimku, kterou by vyhodil zápis. Zápis ho sám spouští jako první, takže obě odpovědi se nemůžou rozejít.
Výraz v řetězci
Uvnitř řetězce v uvozovkách, heredocu nebo příkazu v obrácených apostrofech nerozhodují priority operátorů, ale
gramatika interpolace, a replaceWithExpression() se řídí jí. Holá část řetězce ("x $a y") a
začátek výrazu ve složených závorkách {$...} přijmou jen výraz, na který canStandInString()
odpoví true, tedy proměnnou a to, co z ní vede přes [], ->, :: a
volání. Jiný výraz metoda odmítne výjimkou InvalidArgumentException dřív, než cokoli změní.
Ve složených závorkách výraz zapíše, jak je. Na holé místo ho zapíše holý tam, kde ho řetězec tak přečte stejně, a jinak ho dá do nových složených závorek:
// "x $a y", každý řádek nad původním kódem
$a->replaceWithExpression($builder->expression('$b->c')); // "x $b->c y"
$a->replaceWithExpression($builder->expression('$b[0]')); // "x {$b[0]} y"
$a->replaceWithExpression($builder->expression('$x + 1')); // InvalidArgumentException
$b[$i] by zůstalo holé, $b[0] ne: číslo nebo slovo se jako index v holém zápisu píše jinak
než ve výrazu, třeba "$row[label]" bez uvozovek. Místo uvnitř holé části, třeba $a v
"$a->b", si holý zápis ponechá jen pro prostou proměnnou; cokoli jiného promění celou část ve tvar
{$...}, takže $x[0] dosazené za $a dá "{$x[0]->b}". Jinde ve složených
závorkách a ve tvaru ${výraz} smí stát jakýkoli výraz, v závorkách tam, kde je potřeba, jako
kdekoli jinde.
Dvě místa metoda odmítne vždycky: jméno ve tvaru ${name}, protože výraz by tam pojmenoval proměnnou
proměnné, a složené závorky hned za neescapovaným dolarem textu ("$$a"), které by se přečetly jako
${. Trivia, která výraz do řetězce přinese, dostanou příznak inInterpolation, takže je metody
formátující mezery nechají být, viz Trivia.
Seznamy
$stmts->append($stmt);
$stmts->insert($index, $stmt);
$stmts->insertAfter($return, $stmt); // hned za položku, která v seznamu je
$stmts->insertBefore($return, $stmt);
$stmts->removeItem($stmt);
$args = $call->arguments->items; // SeparatedNodeList, ne ArgumentListNode se závorkami
$arg = $builder->fragment(ArgumentNode::class, '$c');
$args->append($arg); // oddělovač se odvodí z existujících, nebo ', '
$args->insert(0, $builder->fragment(ArgumentNode::class, '$first'));
$args->setTrailingSeparator(null); // pryč s koncovou čárkou
$args->setTrailingSeparator(Token::fromText(',')); // 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č přečte getTrailingSeparator() a nastaví nebo odebere setTrailingSeparator();
prázdný seznam ho nepřijme. Oddělovač se postaví tam, kde poslední položka skončila, tedy před komentář a konec
řádku, které za ní stojí:
// [
// 1,
// 2 // two
// ]
$items->setTrailingSeparator(Token::fromText(','));
// 2, // two
Výjimkou je dokumentační komentář hned za poslední položkou. Ten k položce patří, tak ho čte i
getDocComment() parametru, a oddělovač se proto postaví až za něj: int $b /** the b */,.
Oddělovač bez vlastních trivia převezme koncová trivia poslední položky, nebo trivia oddělovače, který nahrazuje. Odebraný oddělovač nechá své komentáře a konec svého řádku poslední položce, takže komentář s ním nezmizí. Oddělovač s vlastními trivia si je ponechá a komentáře a konec řádku nahrazeného oddělovače následují za nimi.
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().
PlainNodeList 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 = $builder->statement('$this->log("total");');
$return->parent->insertBefore($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ů, vyrobí ho builder: z textu, ze šablony, do které dosadí uzly, jež už držíte, nebo metodou pro konkrétní konstrukci. Vrátí odpojený uzel s prázdnými okraji, připravený k vložení:
// trim($name) → htmlspecialchars(trim($name))
$call->replaceWith($builder->call('htmlspecialchars', [$call]));
Volání trim() pořád stojí v souboru, takže ho builder vezme jako kopii bez okrajů, a
replaceWith() pak na jeho místo postaví nové volání. Jak builder píše operátory, kde sám doplní závorky a
co odmítne, popisuje stránka Nové uzly. Závorky, které chcete v kódu
mít kvůli čtenáři, napíše $builder->parenthesize($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.
Text tokenu a modifikátory
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('(int)'); // jiný zápis téhož druhu tokenu: z '(integer)'
setText() přijme jen jiný zápis téhož druhu tokenu, tedy jinou velikost písmen, (integer) jako
(int), jiné číslo, řetězec nebo jméno. Text, který by lexer přečetl jako jiný druh, odmítne výjimkou
InvalidArgumentException: != jako !== by v tokenu nechalo druh jednoho a text druhého.
Token jiného druhu dosadí Token::replaceWith(), operátor replaceOperator() jeho uzlu, který navíc
doplní závorky, jaké nová priorita žádá:
$token->replaceWith(Token::fromText('!=='));
$binary->replaceOperator('!==');
Token::fromText() pustí na text lexer, takže token dostane druh, jaký by mu dalo PHP. Modifikátory nejsou
sloty, ale tokeny v ModifiersNode, a mění se jeho metodami:
$modifiers = $class->modifiers;
if (!$modifiers->has(Token::Final)) {
$modifiers->append(Token::fromText('final'));
}
if ($readonly = $modifiers->findToken(Token::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.
Typy
Typ parametru, typ vlastnosti a návratový typ se nezapisují přiřazením do slotu, protože s nimi přichází i něco navíc: u návratového typu dvojtečka, u parametru a vlastnosti mezera za typem. Proto se zapisují metodou:
$function->setReturnType($builder->type('void')); // function f(): void
$function->setReturnType(null); // pryč s typem i dvojtečkou
$parameter->setType($builder->type('string')); // string $name
$property->setType($builder->type('?int')); // public ?int $count;
$property->setType(null);
setReturnType() je metoda rozhraní FunctionLikeNode, takže ji mají funkce, metody, closure
i arrow funkce; zapíše typ i s dvojtečkou, nebo obojí odebere, a mezera před tělem zůstane, jaká byla. Hook to
rozhraní implementuje také, ale návratový typ mít nemůže, a proto na setReturnType() odpoví výjimkou
LogicException. ParameterNode::setType() a PropertyNode::setType() zapíšou typ s jednou
mezerou za ním, nebo ho i s mezerou odeberou. Komentáře odebraného typu zůstanou v kódu a typ, který stál na řádku
sám, si vezme i ten řádek. Typ, který stojí ve stromu, vezmou všechny tři metody jako kopii bez trivia na okrajích,
stejně jako builder.
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. Zápis toho, co už na místě stojí, revizi nezvedne, a
průchod, který nic nezměnil, ji proto nechá stejnou.
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.
ensureStartsLine()asetBlankLinesBefore()to dělají správně; kdo skládá trivia ručně, ať to dodrží, jinakgetTrailingSpace()zalomení neuvidí. - Význam. Strom vám dovolí nahradit podmínku čímkoli a odstranit
return; jestli to smíte, je otázka naisRepeatableRead(),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.