Analýzy: NameResolver a Scope

Dvě vestavěné analýzy odpovídají na otázky, které si klade každý nástroj pracující s kódem: co dané jméno doopravdy znamená a jestli je v místě kódu k dispozici $this.

Co je analýza

Strom říká, jak kód vypadá. Analýza je informace odvozená z celého souboru, kterou strom sám nenese: co znamená jméno Order v tomhle souboru nebo jestli je $this k dispozici. Je to obyčejná třída postavená nad FileNode; nic se neregistruje a žádné rozhraní se neimplementuje.

$resolver = new PhpSyntax\Analyses\NameResolver($file);
$scope = new PhpSyntax\Analyses\Scope;

Analýza, která si odvozené informace pamatuje, čte strom tak, jak vypadal, když vznikla; po změně stromu si ji postavte znovu, a že se strom změnil, poznáte podle FileNode::$revision. Týká se to NameResolveru. Scope si nepamatuje nic a nebere ani soubor: odpověď najde procházkou po rodičích uzlu, který dostal, takže ho žádná úprava nezastará.

NameResolver

Překládá jména podle jmenného prostoru a importů, s tím, co PHP dělá za běhu: nekvalifikovaná funkce nebo konstanta, která ve jmenném prostoru není, spadne do globálního.

Importy platí v bloku, ve kterém jsou napsané, kdežto deklarace patří celému jmennému prostoru. Druhý blok namespace A { } téhož souboru proto vidí funkci, kterou deklaroval ten první, ale ne to, co si první blok importoval. Co soubor nedeklaruje, o tom resolver sám neví nic: pád do globálního prostoru je pak odhad podle toho, co je v souboru vidět, ne znalost symbolu. Z odhadu se stane znalost, když resolveru při vytvoření předáte objekt NamespacedSymbols: seznam funkcí a konstant, které jmenné prostory deklarují mimo soubor, a příznak complete, že jiné nejsou. Seznamy přitom mohou být prázdné, a přesně tak to vypadá v projektu, který ve jmenných prostorech žádné funkce ani konstanty nemá. Jestli konkrétní odpověď stojí jen na odhadu, řekne getUnqualifiedResolution().

$symbols = new PhpSyntax\Analyses\NamespacedSymbols(
	functions: ['App\Utils\format'],
	constants: ['App\VERSION'],
	complete: true,
);
$resolver = new PhpSyntax\Analyses\NameResolver($file, $symbols);
$symbols->hasFunction('App\Utils\format');   // true
$symbols->hasConstant('app\VERSION');        // true: jmenný prostor bez ohledu na velikost písmen, jméno konstanty přesně

Jména v NamespacedSymbols musí být plná jména symbolů ve jmenném prostoru; holé format konstruktor odmítne výjimkou InvalidArgumentException, protože globální funkce do seznamu nepatří.

$resolver->getNamespace($node);         // 'App\Model', '' v globálním prostoru
$resolver->getImports(PhpSyntax\SymbolKind::ClassLike, $node);   // alias => plné jméno, platné v místě uzlu
$resolver->getImports(PhpSyntax\SymbolKind::Function, $node);
$resolver->getImports(PhpSyntax\SymbolKind::Constant, $node);

$resolver->resolve($name);              // 'App\Model\Order' z NameNode, třída, funkce nebo konstanta podle místa jména
$resolver->resolveClass($name);         // totéž s druhem symbolu zvoleným předem
$resolver->resolveFunction($name);
$resolver->resolveConstant($name);

$resolver->isGlobalFunctionCall($node, 'sizeof');            // volání globální funkce, případně té jedné
$resolver->findGlobalFunction($call, ['count', 'sizeof']);   // kterou z těch globálních funkcí volání volá, nebo null
$resolver->getUnqualifiedResolution($call->name);            // Global, Namespaced, nebo Uncertain, když pád do globálního stojí jen na odhadu
$resolver->getUnqualifiedResolution('count', PhpSyntax\SymbolKind::Function, $node); // totéž pro jméno zadané textem

Klíčem pole importů je alias tak, jak ho PHP porovnává: u tříd a funkcí, kde na velikosti písmen nezáleží, malými písmeny ('strings' => 'Nette\Utils\Strings'), u konstant tak, jak je zapsaný. Odpověď getUnqualifiedResolution() je výčet PhpSyntax\UnqualifiedResolution. Jméno zadané jako NameNode si s sebou nese druh symbolu i místo, kde stojí; jméno zadané textem potřebuje obojí jako další dva argumenty.

resolve() vybere mezi resolveClass(), resolveFunction() a resolveConstant() podle vlastnosti $symbolKind jména, tedy podle toho, kde jméno stojí; odpojené jméno čte jako třídu. Všechny čtyři chtějí jméno, které na nějaký symbol opravdu odkazuje, a ne každé NameNode takové je: int v typu pojmenovává vestavěný typ, Foo v use Foo; symbol teprve zavádí a self zastupuje třídu jen tam, kde je napsané. Na takové jméno resolver vyhodí InvalidArgumentException, místo aby ho tiše přečetl jako třídu jmenného prostoru a vrátil nesmysl App\int. Kdo prochází všechna jména v souboru, ptá se proto napřed isReference():

foreach ($file->find(NameNode::class) as $name) {
	if ($name->isReference()) {
		echo $resolver->resolve($name);
	}
}

Jedna odchylka tu je: resolveClass() na self, static a parent nespadne, ale vrátí je tak, jak jsou zapsané.

isGlobalFunctionCall() je otázka, kterou si dřív každý nástroj řešil sám a většinou hůř: uzel musí být volání funkce se jménem (ne $f() ani $obj->f()), jméno nesmí být klíčové slovo a po překladu musí být globální. Tak se pozná, že count($a) uvnitř namespace App volá count, a ne App\count, pokud taková funkce není importovaná, deklarovaná v souboru ani uvedená v NamespacedSymbols. Bez úplného NamespacedSymbols je to ale odhad: App\count() může deklarovat jiný soubor projektu, který resolver nevidí, a PHP by pak zavolalo ji. Když vás zajímá několik funkcí naráz, findGlobalFunction() vrátí tu z nich, kterou volání volá, a to tak, jak je napsaná v seznamu, bez ohledu na velikost písmen v kódu; za globální počítá volání stejně jako isGlobalFunctionCall().

Nástroj, který podle odpovědi mění kód, se proto má zeptat i na getUnqualifiedResolution(). Takhle rozhoduje DressCode, jestli je oprava riziková: s odpovědí Uncertain ano, s Global nebo Namespaced ne. Metoda přijímá jen nekvalifikované jméno, protože jméno s lomítkem do globálního prostoru nepadá, a na jiné vyhodí InvalidArgumentException. Odpověď Global přitom říká, že jméno míří do globálního prostoru, ne nutně na symbol toho jména: import s aliasem ho může přesměrovat jinam.

Jméno, které jste právě naparsovali a ve stromu ještě nestojí, samo neví, kde bude; kde ho chcete přeložit, řekne druhý argument:

$name = $builder->name('Strings');
$resolver->resolveClass($name, $at);    // přeloží se tak, jak by platilo v místě $at

Uzel z cizího stromu je odmítnut výjimkou, ne zodpovězen v globálním jmenném prostoru. Tichá platná odpověď na nepoloženou otázku je horší než chyba.

Cesta zpátky je stejně užitečná: jak plné jméno zapsat v místě, kde stojí uzel.

$kind = PhpSyntax\SymbolKind::ClassLike;

$resolver->shortenName('Nette\Utils\Strings', $kind, $at);   // 'Strings', je importované
$resolver->shortenName('App\Model\Order', $kind, $at);       // 'Order' v namespace App\Model
$resolver->shortenName('Other\Thing', $kind, $at);           // '\Other\Thing', jinak by se hledalo v namespace
$resolver->isAliasFree('Strings', $kind, $at);                // false, ten alias už něco znamená

shortenName() vezme v úvahu aliasy, prefixové importy i jmenný prostor a vrátí nejkratší zápis, který na tom místě znamená totéž. To „znamená totéž“ je tvrdá podmínka: kde je jméno obsazené importem, vrátí radši delší zápis. S use function X\count; v souboru tak globální count dostane '\count', protože holé count by na tom místě znamenalo X\count. Ze stejného důvodu dostane globální funkce nebo konstanta ve jmenném prostoru holé jméno jen tehdy, když resolver dostal NamespacedSymbols s příznakem complete; bez té znalosti by holé count mohlo znamenat App\count z jiného souboru. Přečtením zpátky přes resolveFunction() a spol. se tedy vždycky vrátíte k symbolu, na který jste se ptali. isAliasFree() odpoví na otázku, kterou si klade každý, kdo chce import doplnit: je tohle jméno tady ještě volné?

A ještě tři metody, tentokrát o deklaracích v souboru:

$resolver->getDeclaredName($class);              // 'App\Cart' z uzlu deklarace
$resolver->findDeclaration('App\Cart', $kind);   // ClassLikeNode, nebo null
$resolver->findNamespacedDeclarations($file);    // funkce a konstanty, které uzel deklaruje do jmenného prostoru

findDeclaration() hledá mezi symboly jednoho druhu, protože PHP drží třídu, funkci a konstantu stejného jména odděleně: SymbolKind::ClassLike najde třídu, rozhraní, trait nebo výčet, Function funkci a Constant položku příkazu const. Velikost písmen bere tak jako PHP, tedy u konstanty ji rozlišuje jen v jejím vlastním jméně, a úvodní backslash mu nevadí. Počítají se i deklarace vnořené v podmínce nebo v těle funkce, a když soubor totéž jméno deklaruje víckrát, třeba pod if (!function_exists(...)), dostanete tu první. getDeclaredName() vrátí jméno i pro takovou konstantu; konstanta třídy do jmenného prostoru nic nezavádí, a proto dostane null.

findNamespacedDeclarations() vrátí funkce a konstanty, které uzel deklaruje do jmenného prostoru, jako seznam objektů Declaration s druhem symbolu ($kind), plným jménem ($name) a uzlem, který ho pojmenovává ($node), v pořadí, v jakém jsou napsané. Počítá i deklarace v podmínce nebo v těle funkce a konstanty, které define() pojmenuje řetězcem; co se deklaruje do globálního prostoru, mezi nimi není. Je to přesně to, co patří do NamespacedSymbols: projdete jím soubory projektu a z výsledku sestavíte úplný seznam pro resolver každého z nich.

Scope

Scope odpoví, jestli je v místě uzlu nebo tokenu k dispozici $this:

$scope->hasThis($node);   // v nestatické metodě nebo hooku a v closure uvnitř nich

hasThis() bere v úvahu i to, že closure i arrow funkce $this od okolí převezmou samy, bez use, kdežto static function a static fn ne. Property hook ho má vždy, protože patří k objektu.

Funkce a třída, ve které kód stojí, žádnou analýzu nepotřebují, stačí jít po rodičích:

$node->findAncestor(FunctionLikeNode::class);   // funkce, metoda, closure, arrow funkce nebo hook, nebo null
$node->findAncestor(ClassLikeNode::class);      // třída, rozhraní, trait, výčet nebo anonymní třída, nebo null
$closure->getCapturedVariableNames();           // proměnné z use (...), bez dolaru: ['a', 'b']

Totéž funguje i od tokenu, viz Nahoru a do stran.

Vlastní analýza

Když víc míst potřebuje tutéž informaci o souboru, napište ji jako třídu s konstruktorem přijímajícím FileNode. Nástroj postavený na PhpSyntax si takovou analýzu obvykle staví a zahazuje sám podle revize stromu; DressCode to dělá za pravidla a přidává k oběma vestavěným ještě PhpDoc nad dokumentačními komentáři, protože ten už potřebuje parser phpDocu, který sem nepatří.