Two weeks ago, FluidStan 0.1 was released, static analysis for Fluid templates built on PHPStan. How the analysis works is described in part one. Version 0.3 has been released since.
In 0.1 every variable that did not come from f:argument, f:for or f:variable was either mixed or undefined. ViewHelpers in Fluid are allowed to set variables, though, and in TYPO3 projects they frequently do. 0.3 detects those assignments.
Variables from ViewHelpers
Fluid ships five ViewHelpers that write variables into a template: f:variable, f:alias, f:cycle, f:for and f:groupedFor. The transpiler models those itself. Any other ViewHelper can do the same through the variable provider. An example from the TYPO3 core:
<f:link.typolink parameter="{link}" partsAs="parts">
{parts.href} / {parts.target}
</f:link.typolink>
{parts} does not come from the template, it comes from the ViewHelper. Before 0.3 the variable was undefined to FluidStan.
Since 0.3 this is detected without configuration. Before the template analysis, PHPStan runs once over the classes of the ViewHelpers used in the templates and records every add() and addAll() on a variable provider, along with the type of the value.
The variable name is not always constant, it often comes from an argument of the ViewHelper. $provider->add($this->arguments['partsAs'], ...) does not assign a variable called partsAs, it assigns the one whose name the call site passes in partsAs. The analysis traces the name back through casts, local variables and toString()-like helpers to the argument. The assignment is then named after the value of the partsAs argument at each call site, or after that argument’s default when the call omits it. A call whose argument is not a constant string assigns nothing.
If the assignment is not reached on every path, after an early return or inside a condition, the variable may remain unset. In Fluid that corresponds to null, so the type gets |null.
The pass is cached in tmpDir, per set of ViewHelper files by their content and by the FluidStan and PHPStan versions. It only runs again when one of those files or versions changed. Not tracked is a type a ViewHelper takes from another file, such as a repository’s return type. When that changes, touch the ViewHelper file or delete the cache.
Child scope
A ViewHelper can also give a variable to its children only, as f:alias and f:cycle do. Fluid offers two ways to do this, both are modelled. A ScopedVariableProvider around a StandardVariableProvider restores the previous value after the tag. The combination of add(), renderChildren() and remove() deletes the variable for good, so it is null afterwards, even if the component collection provided it beforehand.
This also fixes two older bugs: f:alias and f:cycle were treated as persistent before. f:alias additionally assigned nothing at all, because Fluid hands over the map argument wrapped in a root node, which the check for an array missed. The aliases were unknown variables inside the tag.
Declaring assignments manually
When the name is computed or the ViewHelper delegates to a service, detection cannot follow. The assignment is then declared, either in fluidstan.neon:
parameters:
viewHelperAssignments:
# ViewHelper class => variable => PHPDoc type
My\Site\ViewHelpers\ClassBuilderViewHelper:
'{target}': string
My\Site\ViewHelpers\LoadProductViewHelper:
product: My\Site\Domain\Model\Product
# children only; scope "removed" when the ViewHelper deletes it afterwards
My\Site\ViewHelpers\WithProductViewHelper:
'{as}': { type: My\Site\Domain\Model\Product, scope: children }
{target} names the variable after the call’s target argument, or after that argument’s default.
Or in the class docblock, which for your own ViewHelpers sits closer to the code:
/**
* @fluidstan-assigns {target} string
* @fluidstan-assigns-to-children {as} My\Site\Domain\Model\Product
*/
final class ClassBuilderViewHelper extends AbstractViewHelper
Subclasses inherit detected and declared assignments alike. The configuration takes precedence over the tag, the tag over detection. After the call the variable is local: it has the declared type, and a read before the first call is still reported.
Variable scoping
The analysis models Fluid’s scoping. A variable assigned by f:variable keeps its local value on the paths where it was assigned and the collection’s value everywhere else, across conditions and loop iterations. Loop variables shadow a value for the loop only and release it afterwards, like Fluid’s ForViewHelper.
This is covered by an integration test that renders 23 templates in every input variant through real Fluid and checks that the analysis assumes the same values.
Type narrowing through conditions
Inside <f:if condition="{item}"> the value of item is not null, not false, not an empty string and not 0. The same applies to every operand of an && chain, inside f:then and inside f:else if="{other}" for other.
The conditions are not re-implemented. Fluid’s BooleanParser compiles PHP code for every condition, and that code is reused with the operands substituted. Grammar, precedence and comparison semantics therefore match Fluid.
Narrowing does not happen in these cases:
- the
f:elsebranch and a negated check!{item}: Fluid evaluates the string"false"and an emptyCountableasfalse, PHP does not. FluidStan cannot assume the value is empty there. - the operands of
|| - a condition on a property path:
{item.title}says nothing aboutitemitself - the loose comparisons
==and!=, even against a literal.{label} == 'abc'leaveslabelastring|null. Fluid applies the PHP operator, and PHPStan deliberately does not narrow on loose comparisons.===,!==,<,<=,>and>=narrow as they do in PHP. - comparisons with a value that can be a
CountableorUnsafeHTML: Fluid compares a bool or a string in their place, so an emptyObjectStorageequals0there.
The last point was broken under Fluid 4. Every operand counted as a possible string there, because PHPStan was asked whether it is an UnsafeHTML, an interface Fluid 4 does not have. The answer was “maybe”, so comparisons with an object operand never narrowed. Fixed in 0.3.
Variables from component collections
When a component collection provides variables through getAdditionalVariables(), they are known without the method being executed. PHPStan infers the array shape from its return statements, as it does for closure return types. A collection that builds ['component' => ['class' => ..., 'prefix' => ...]] gives {component.class} the type string, and {component.nonexistent} is an error, without a docblock. A @return array shape on the method takes precedence, a @var in the template overrides both.
Variables that are neither arguments nor provided by the collection are still reported as undefined, now with the collection named in the message. A component of a collection counts as a component even without f:argument.
Strict mode
The default checks report what Fluid rejects or what cannot work. strict: true additionally reports what Fluid converts or tolerates at runtime without a notice:
fluidstan.argument.cast: a scalar value at astring,intorfloatargument that Fluid casts with a loss of information. A string tointbecomes0, a bool tostringbecomes1or an empty string. An integer or an integer-like numeric string forintand a number forstringstill pass.fluidstan.property.nullable: a path through a value that may be null, such as{item.parent.title}with a nullableparent. Inside<f:if condition="{item.parent}">the value is set and nothing is reported.if.alwaysTrue,if.alwaysFalseand related identifiers: a condition the types already decide, such as<f:if condition="{item}">on an object that cannot be null. Reported by PHPStan’s own rules on the generated code. A string stays open, because"false"and"0"evaluate tofalse, and so does aCountable, which may be empty.fluidstan.comparison.loose: a comparison of scalars of different kinds,{count} == '5'or{flag} == 1. Fluid compares with PHP’s loose operators, so the result depends on type juggling.===and!==are never reported.fluidstan.output.bool: a bool rendered into the output, which prints1or nothing.
Without the setting the analysis runs as before, and the command points to strict mode after the results. strict: false ends the note.
The example extension from the TYPO3 package, analysed with strict: true. Line 18 names the component collection that does not provide the variable, line 5 in Badge.html is a strict mode report.
Baseline
For existing projects with many reports, the current state can be recorded as a baseline:
vendor/bin/fluidstan --generate-baseline
vendor/bin/fluidstan --generate-baseline=build/baseline.neon
Every current error is written as an ignoreErrors entry in PHPStan’s format, with the message as a pattern, the identifier, the count and the template path relative to the baseline file. Include it through includes in fluidstan.neon and only new errors are reported.
ignoreErrors takes what PHPStan takes: a pattern or a map with message and/or identifier, path or paths, count and 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
The matching, the counting and the report of an entry without a match are PHPStan’s own code and behave accordingly: with a count, that many occurrences are ignored and a differing number is reported as ignore.count; an entry without a match is reported as ignore.unmatched unless its reportUnmatched: false or the global reportUnmatchedIgnoredErrors: false says otherwise.
Schema validation of the configuration
Previously an unknown parameter in fluidstan.neon was ignored, so a typo in a parameter name stayed without effect and unnoticed. Since 0.3 the file is validated against a schema, as PHPStan validates its own. An unknown or mistyped parameter stops the run with Nette’s message, such as “Unexpected item ‘nope’”.
phpstan.level was removed as well, including the -l option. The setting had no effect: FluidStan’s checks implement Fluid’s semantics and run at any level, and the generated code contains none of the constructs the levels add checks for. A configuration that sets phpstan.level is now rejected with a message.
TYPO3 boot
In 0.1, TYPO3 was booted in failsafe mode, which knows the system extensions only. Neither ext_localconf.php nor Configuration/Fluid/Namespaces.php of the project’s extensions were loaded, so globally registered namespaces had to be repeated in fluidstan.neon.
TYPO3 is now booted like a CLI command, still without a database connection. The namespaces come from TYPO3’s ViewHelperResolverFactory: every Namespaces.php (TYPO3 14), TYPO3_CONF_VARS and the ModifyNamespacesEvent, in TYPO3’s order. A project without config/system/settings.php is reported instead of being analysed with incomplete namespaces. A minimal settings.php with SYS/encryptionKey is sufficient.
The package also knows the component collections TYPO3 14 defines declaratively through Configuration/Fluid/ComponentCollections.php, without a PHP class, as well as collection classes registered through dependency injection. Their variables come from the ProvideStaticVariablesToComponentEvent at runtime and stay mixed in the analysis.
Supported are TYPO3 13.4 and 14.2 or newer. TYPO3 14.1 is not supported: it has the declarative collections, but not yet the delegate registry that hands them to the resolver.
Upgrading
composer require --dev fluidstan/typo3:^0.3
In a TYPO3 project the TYPO3 package is enough, the core comes along as a dependency. Without TYPO3: composer require --dev fluidstan/fluidstan:^0.3.
Three points to observe when upgrading:
- Remove
phpstan.leveland every other parameter FluidStan does not know fromfluidstan.neon. The error message names the parameter. - Set strict mode:
strict: truefor the additional checks,strict: falsefor the previous behaviour without the note. - For a step by step adoption, run
vendor/bin/fluidstan --generate-baselineand addincludes: [fluidstan-baseline.neon].
fluidstan/typo3 0.2 keeps working with core 0.3, and 0.3 requires it. Core 0.2 declares a conflict with fluidstan/typo3 below 0.2, because the environment interface changed there: getResolverDelegates() was added, and getNamespaces() may return null for an ignored namespace.
Open points
Sections, partials and layouts are still analysed with mixed variables rather than with the types of their call sites. Type inference from assign() and assignMultiple() in PHP code is missing, a result cache as well. Open on the TYPO3 side: TYPO3’s own variables ({settings}, {data}, {page}, {site}) typed out of the box, Content Blocks field definitions as the type of {data}, partials and layouts resolved through site sets and TypoScript, and Extbase relations unwrapped from ObjectStorage and lazy-loading proxies.
Thanks to Simon Praetorius (@s2b) for the feedback and the ideas that went into it.
Code, releases and the example extension: fluidstan/fluidstan and fluidstan/typo3. If the analysis judges a case wrongly, please report it as an issue on GitHub.