Vor zwei Wochen ist FluidStan 0.1 erschienen, die statische Analyse für Fluid-Templates auf Basis von PHPStan. Wie die Analyse arbeitet, steht im ersten Teil. Inzwischen ist Version 0.3 erschienen.
In 0.1 war jede Variable, die nicht aus f:argument, f:for oder f:variable kam, entweder mixed oder undefiniert. ViewHelper dürfen in Fluid aber Variablen setzen, und in TYPO3-Projekten passiert das häufig. 0.3 erkennt diese Zuweisungen.
Variablen aus ViewHelpern
Fluid bringt fünf ViewHelper mit, die Variablen in ein Template schreiben: f:variable, f:alias, f:cycle, f:for und f:groupedFor. Die bildet der Transpiler selbst ab. Jeder andere ViewHelper kann dasselbe über den Variable Provider tun. Ein Beispiel aus dem TYPO3-Core:
<f:link.typolink parameter="{link}" partsAs="parts">
{parts.href} / {parts.target}
</f:link.typolink>
{parts} kommt nicht aus dem Template, sondern aus dem ViewHelper. Vor 0.3 war die Variable für FluidStan undefiniert.
Seit 0.3 wird das ohne Konfiguration erkannt. Vor der Template-Analyse läuft PHPStan einmal über die Klassen der ViewHelper, die in den Templates vorkommen, und protokolliert jedes add() und addAll() auf einem Variable Provider samt dem Typ des Werts.
Der Variablenname ist dabei nicht immer konstant, er kommt oft aus einem Argument des ViewHelpers. $provider->add($this->arguments['partsAs'], ...) weist nicht die Variable partsAs zu, sondern die, deren Namen die Aufrufstelle in partsAs übergibt. Die Analyse verfolgt den Namen durch Casts, lokale Variablen und toString()-artige Helfer bis zum Argument zurück. Die Zuweisung heißt dann wie der Wert des Arguments partsAs an der jeweiligen Aufrufstelle, oder wie dessen Default, wenn der Aufruf das Argument weglässt. Ein Aufruf, dessen Argument kein konstanter String ist, weist nichts zu.
Wird die Zuweisung nicht auf jedem Pfad erreicht, etwa nach einem frühen return oder innerhalb einer Bedingung, kann die Variable ungesetzt bleiben. In Fluid entspricht das null, der Typ bekommt also |null dazu.
Der Vorlauf ist im tmpDir gecacht, pro Satz von ViewHelper-Dateien über deren Inhalt und über die FluidStan- und PHPStan-Version. Er läuft nur neu, wenn sich eine der Dateien oder eine der Versionen ändert. Nicht mitverfolgt wird ein Typ, den ein ViewHelper aus einer anderen Datei bezieht, etwa der Rückgabetyp eines Repositories. Ändert der sich, muss die ViewHelper-Datei angefasst oder der Cache gelöscht werden.
Child-Scope
Ein ViewHelper kann eine Variable auch nur seinen Kindern geben, wie f:alias und f:cycle es tun. Fluid bietet dafür zwei Wege, beide sind abgebildet. Ein ScopedVariableProvider um einen StandardVariableProvider stellt den vorherigen Wert nach dem Tag wieder her. Die Kombination aus add(), renderChildren() und remove() löscht die Variable endgültig, sie ist danach null, auch wenn die Component Collection sie vorher geliefert hat.
Damit sind zwei ältere Fehler behoben: f:alias und f:cycle galten bisher als dauerhaft. f:alias hat außerdem gar nichts zugewiesen, weil Fluid das map-Argument in einen Root-Node verpackt übergibt und die Prüfung auf ein Array daran vorbeigriff. Die Aliase waren innerhalb des Tags unbekannte Variablen.
Zuweisungen manuell deklarieren
Wird der Name berechnet oder delegiert der ViewHelper an einen Service, kommt die Erkennung nicht mit. Dann wird die Zuweisung deklariert, entweder in der fluidstan.neon:
parameters:
viewHelperAssignments:
# ViewHelper-Klasse => Variable => PHPDoc-Typ
My\Site\ViewHelpers\ClassBuilderViewHelper:
'{target}': string
My\Site\ViewHelpers\LoadProductViewHelper:
product: My\Site\Domain\Model\Product
# nur für die Kinder; scope "removed", wenn der ViewHelper sie danach löscht
My\Site\ViewHelpers\WithProductViewHelper:
'{as}': { type: My\Site\Domain\Model\Product, scope: children }
{target} benennt die Variable nach dem Argument target der Aufrufstelle, ersatzweise nach dessen Default.
Oder im Docblock der Klasse, bei eigenen ViewHelpern näher am Code:
/**
* @fluidstan-assigns {target} string
* @fluidstan-assigns-to-children {as} My\Site\Domain\Model\Product
*/
final class ClassBuilderViewHelper extends AbstractViewHelper
Subklassen erben erkannte und deklarierte Zuweisungen gleichermaßen. Die Konfiguration hat Vorrang vor dem Tag, der Tag vor der Erkennung. Nach dem Aufruf ist die Variable lokal: Sie hat den deklarierten Typ, und ein Lesezugriff vor dem ersten Aufruf wird weiterhin gemeldet.
Variablen-Scoping
Die Analyse bildet Fluids Scoping ab. Eine mit f:variable zugewiesene Variable behält ihren lokalen Wert auf den Pfaden, auf denen sie zugewiesen wurde, und den Wert der Collection überall sonst, über Bedingungen und Schleifendurchläufe hinweg. Schleifenvariablen verdecken einen Wert nur für die Schleife und geben ihn danach wieder frei, wie Fluids ForViewHelper.
Abgesichert ist das über einen Integrationstest, der 23 Templates in jeder Eingabevariante durch echtes Fluid rendert und prüft, dass die Analyse dieselben Werte annimmt.
Typverengung durch Bedingungen
Innerhalb von <f:if condition="{item}"> ist item nicht null, nicht false, kein leerer String und nicht 0. Das gilt für jeden Operanden einer &&-Kette, innerhalb von f:then und in f:else if="{other}" für other.
Die Bedingungen sind nicht nachgebaut. Fluids BooleanParser kompiliert für jede Bedingung PHP-Code, und dieser Code wird mit substituierten Operanden wiederverwendet. Grammatik, Präzedenz und Vergleichssemantik entsprechen damit Fluid.
Nicht verengt wird in diesen Fällen:
- der
f:else-Zweig und eine negierte Prüfung!{item}: Fluid wertet den String"false"und ein leeresCountablealsfalseaus, PHP nicht. FluidStan kann dort nicht annehmen, dass der Wert leer ist. - die Operanden von
|| - eine Bedingung auf einem Property-Pfad:
{item.title}sagt nichts überitemselbst aus - die lockeren Vergleiche
==und!=, auch gegen ein Literal.{label} == 'abc'lässtlabeleinstring|null. Fluid wendet den PHP-Operator an, und PHPStan verengt bei lockeren Vergleichen bewusst nicht.===,!==,<,<=,>und>=verengen wie in PHP. - Vergleiche mit einem Wert, der ein
CountableoderUnsafeHTMLsein kann: Fluid vergleicht an deren Stelle einen Bool oder einen String, ein leeresObjectStorageist dort gleich0.
Der letzte Punkt war unter Fluid 4 fehlerhaft. Dort galt jeder Operand als möglicher String, weil PHPStan gefragt wurde, ob er ein UnsafeHTML ist, ein Interface, das Fluid 4 nicht hat. Die Antwort lautete “vielleicht”, und Vergleiche mit einem Objekt-Operanden haben deshalb nie verengt. Behoben in 0.3.
Variablen aus Component Collections
Liefert eine Component Collection über getAdditionalVariables() Variablen, sind die bekannt, ohne dass die Methode ausgeführt wird. PHPStan leitet den Array-Shape aus den Return-Statements ab, wie bei Closure-Rückgabetypen. Eine Collection, die ['component' => ['class' => ..., 'prefix' => ...]] zusammenbaut, gibt {component.class} den Typ string, und {component.nonexistent} ist ein Fehler, ohne Docblock. Ein @return mit Array-Shape an der Methode hat Vorrang, ein @var im Template überschreibt beides.
Variablen, die weder Argument sind noch von der Collection kommen, werden weiterhin als undefiniert gemeldet, jetzt mit dem Namen der Collection in der Meldung. Eine Component einer Collection gilt auch ohne f:argument als Component.
Strict Mode
Die Standardprüfungen melden, was Fluid ablehnt oder was nicht funktionieren kann. strict: true meldet zusätzlich, was Fluid zur Laufzeit ohne Hinweis konvertiert oder toleriert:
fluidstan.argument.cast: ein skalarer Wert an einemstring-,int- oderfloat-Argument, den Fluid unter Informationsverlust konvertiert. Ein String anintwird0, ein Bool anstringwird1oder ein leerer String. Ein Integer oder ein integer-artiger numerischer String anintund eine Zahl anstringgehen weiterhin durch.fluidstan.property.nullable: ein Pfad durch einen Wert, der null sein kann, etwa{item.parent.title}bei nullbaremparent. Innerhalb von<f:if condition="{item.parent}">ist der Wert gesetzt und es wird nichts gemeldet.if.alwaysTrue,if.alwaysFalseund verwandte Identifier: eine Bedingung, die die Typen bereits entscheiden, etwa<f:if condition="{item}">auf einem Objekt, das nicht null sein kann. Gemeldet von PHPStans eigenen Rules auf dem generierten Code. Bei einem String wird nichts gemeldet, weil"false"und"0"zufalseausgewertet werden, bei einemCountableebenfalls nicht, weil es leer sein kann.fluidstan.comparison.loose: ein Vergleich von Skalaren verschiedener Art,{count} == '5'oder{flag} == 1. Fluid vergleicht mit PHPs lockeren Operatoren, das Ergebnis hängt am Type Juggling.===und!==werden nie gemeldet.fluidstan.output.bool: ein Bool in der Ausgabe, der1oder nichts druckt.
Ohne die Einstellung läuft die Analyse wie bisher, und das Kommando weist nach den Ergebnissen auf den Strict Mode hin. strict: false beendet den Hinweis.
Die Beispiel-Extension aus dem TYPO3-Paket, analysiert mit strict: true. Zeile 18 nennt die Component Collection, die die Variable nicht liefert, Zeile 5 in Badge.html ist eine Meldung des Strict Mode.
Baseline
Für bestehende Projekte mit vielen Meldungen lässt sich der aktuelle Stand als Baseline festschreiben:
vendor/bin/fluidstan --generate-baseline
vendor/bin/fluidstan --generate-baseline=build/baseline.neon
Geschrieben wird jeder aktuelle Fehler als ignoreErrors-Eintrag in PHPStans Format, mit der Meldung als Pattern, dem Identifier, der Anzahl und dem Template-Pfad relativ zur Baseline-Datei. Eingebunden wird sie über includes in der fluidstan.neon, danach werden nur noch neue Fehler gemeldet.
ignoreErrors akzeptiert dieselben Angaben wie in PHPStan: ein Pattern oder eine Map mit message und/oder identifier, path beziehungsweise paths, count und reportUnmatched.
parameters:
ignoreErrors:
- '#^Variable "legacy" is not defined#'
-
identifier: fluidstan.output.bool
paths:
- Components/Atom/Toggle/*.html
-
message: '#^Cannot access "length" on int\.$#'
path: Components/Molecule/Card/Card.html
count: 3
Das Matching, das Zählen und der Report eines Eintrags ohne Treffer sind PHPStans eigener Code und verhalten sich entsprechend: Mit count wird genau diese Anzahl ignoriert, eine abweichende als ignore.count gemeldet, ein Eintrag ohne Treffer als ignore.unmatched, sofern nicht reportUnmatched: false oder global reportUnmatchedIgnoredErrors: false gesetzt ist.
Schema-Validierung der Konfiguration
Bisher wurde ein unbekannter Parameter in der fluidstan.neon ignoriert, ein Tippfehler im Parameternamen blieb damit wirkungslos und unbemerkt. Seit 0.3 wird die Datei gegen ein Schema validiert, wie PHPStan seine eigene Konfiguration validiert. Ein unbekannter oder falsch getypter Parameter stoppt den Lauf mit einer Meldung von Nette, etwa “Unexpected item ‘nope’”.
Weggefallen ist außerdem phpstan.level, inklusive der Option -l. Die Einstellung hatte keine Wirkung: FluidStans Prüfungen bilden Fluids Semantik ab und laufen auf jedem Level, und der generierte Code enthält keine der Konstruktionen, für die die Level zusätzliche Prüfungen einschalten. Eine Konfiguration, die phpstan.level setzt, wird jetzt mit einer Meldung abgelehnt.
TYPO3-Boot
In 0.1 wurde TYPO3 im Failsafe-Modus gebootet, der nur die System-Extensions kennt. Weder ext_localconf.php noch Configuration/Fluid/Namespaces.php der Projekt-Extensions wurden geladen, global registrierte Namespaces mussten deshalb in der fluidstan.neon wiederholt werden.
Inzwischen wird TYPO3 wie ein CLI-Befehl gebootet, weiterhin ohne Datenbankverbindung. Die Namespaces kommen aus TYPO3s ViewHelperResolverFactory: jede Namespaces.php (TYPO3 14), TYPO3_CONF_VARS und das ModifyNamespacesEvent, in TYPO3s Reihenfolge. Ein Projekt ohne config/system/settings.php wird gemeldet, statt mit unvollständigen Namespaces analysiert zu werden. Eine minimale settings.php mit SYS/encryptionKey genügt.
Das Paket kennt außerdem die Component Collections, die TYPO3 14 deklarativ über Configuration/Fluid/ComponentCollections.php definiert, ohne PHP-Klasse, sowie Collection-Klassen, die über Dependency Injection registriert sind. Deren Variablen kommen zur Laufzeit aus dem ProvideStaticVariablesToComponentEvent und bleiben in der Analyse mixed.
Unterstützt werden TYPO3 13.4 und 14.2 oder neuer. TYPO3 14.1 wird nicht unterstützt: Es hat die deklarativen Collections, aber noch nicht die Delegate-Registry, die sie an den Resolver übergibt.
Upgrade
composer require --dev fluidstan/typo3:^0.3
In einem TYPO3-Projekt reicht das TYPO3-Paket, der Core kommt als Abhängigkeit mit. Ohne TYPO3: composer require --dev fluidstan/fluidstan:^0.3.
Beim Umstieg sind drei Punkte zu beachten:
phpstan.levelund alle anderen Parameter, die FluidStan nicht kennt, aus derfluidstan.neonentfernen. Die Fehlermeldung benennt den Parameter.- Strict Mode setzen:
strict: truefür die zusätzlichen Prüfungen,strict: falsefür das bisherige Verhalten ohne den Hinweis. - Für einen schrittweisen Einstieg
vendor/bin/fluidstan --generate-baselineausführen undincludes: [fluidstan-baseline.neon]ergänzen.
fluidstan/typo3 0.2 läuft mit dem Core 0.3 weiter, 0.3 setzt ihn voraus. Der Core 0.2 deklariert einen Konflikt mit fluidstan/typo3 unterhalb von 0.2, weil sich dort das Environment-Interface geändert hat: getResolverDelegates() ist dazugekommen, und getNamespaces() darf null für einen ignorierten Namespace zurückgeben.
Offene Punkte
Sections, Partials und Layouts werden weiterhin mit mixed-Variablen analysiert statt mit den Typen ihrer Aufrufstellen. Typinferenz aus assign() und assignMultiple() im PHP-Code fehlt, ein Result-Cache ebenfalls. Auf der TYPO3-Seite offen: TYPO3s eigene Variablen ({settings}, {data}, {page}, {site}) ab Werk typisiert, Content-Blocks-Felddefinitionen als Typ von {data}, die Auflösung von Partials und Layouts über Site Sets und TypoScript sowie das Auspacken von Extbase-Relationen aus ObjectStorage und Lazy-Loading-Proxies.
Danke an Simon Praetorius (@s2b) für das Feedback und die Ideen, die eingeflossen sind.
Code, Releases und die Beispiel-Extension: fluidstan/fluidstan und fluidstan/typo3. Fälle, die die Analyse falsch beurteilt, bitte als Issues auf GitHub melden.