diff --git a/doc/tags/type.rst b/doc/tags/type.rst new file mode 100644 index 00000000000..696368b0181 --- /dev/null +++ b/doc/tags/type.rst @@ -0,0 +1,9 @@ +``type`` +============== + +Twig can perform more performant access, when type of variables can be predicted. +To support prediction you can provide type hints, that you already know from PHP: + +.. code-block:: twig + + {% type interval \DateInterval %} diff --git a/src/Environment.php b/src/Environment.php index c6ba8b2c0fe..3d0a8a43da1 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -33,6 +33,8 @@ use Twig\NodeVisitor\NodeVisitorInterface; use Twig\RuntimeLoader\RuntimeLoaderInterface; use Twig\TokenParser\TokenParserInterface; +use Twig\TypeHint\ContextStack; +use Twig\TypeHint\TypeInterface; /** * Stores the Twig configuration and renders templates. @@ -69,6 +71,7 @@ class Environment private $optionsHash; /** @var bool */ private $useYield; + private $typeHintStack; /** * Constructor. @@ -127,6 +130,7 @@ public function __construct(LoaderInterface $loader, $options = []) $this->strictVariables = (bool) $options['strict_variables']; $this->setCache($options['cache']); $this->extensionSet = new ExtensionSet(); + $this->typeHintStack = new ContextStack(); $this->addExtension(new CoreExtension()); $this->addExtension(new EscaperExtension($options['autoescape'])); @@ -845,6 +849,11 @@ public function getBinaryOperators(): array return $this->extensionSet->getBinaryOperators(); } + public function getTypeHintStack(): ContextStack + { + return $this->typeHintStack; + } + private function updateOptionsHash(): void { $this->optionsHash = implode(':', [ diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index b11e2e5e237..c4ce71aefa1 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -74,6 +74,7 @@ use Twig\TokenParser\IncludeTokenParser; use Twig\TokenParser\MacroTokenParser; use Twig\TokenParser\SetTokenParser; +use Twig\TokenParser\TypeHintTokenParser; use Twig\TokenParser\UseTokenParser; use Twig\TokenParser\WithTokenParser; use Twig\TwigFilter; @@ -178,6 +179,7 @@ public function getTokenParsers(): array new EmbedTokenParser(), new WithTokenParser(), new DeprecatedTokenParser(), + new TypeHintTokenParser(), ]; } diff --git a/src/Extension/TypeOptimizerExtension.php b/src/Extension/TypeOptimizerExtension.php new file mode 100644 index 00000000000..6ce550213ea --- /dev/null +++ b/src/Extension/TypeOptimizerExtension.php @@ -0,0 +1,32 @@ + + */ +final class TypeOptimizerExtension extends AbstractExtension +{ + public function getTokenParsers(): array + { + return []; + } + + public function getNodeVisitors(): array + { + return [new TypeEvaluateNodeVisitor()]; + } +} diff --git a/src/Lexer.php b/src/Lexer.php index 9e4d6119eb7..c40f31f88ed 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -412,7 +412,27 @@ private function lexComment(): void throw new SyntaxError('Unclosed comment.', $this->lineno, $this->source); } - $this->moveCursor(substr($this->code, $this->cursor, $match[0][1] - $this->cursor).$match[0][0]); + $text = substr($this->code, $this->cursor, $match[0][1] - $this->cursor) . $match[0][0]; + + if (preg_match('/(.*@var\s+)([a-z][a-z0-9]*)(\s+)(\S+)(.*)$/is', $text, $hintMatch) === 1) { + $this->pushToken(Token::BLOCK_START_TYPE); + $this->moveCursor($hintMatch[1]); + + $this->pushToken(Token::NAME_TYPE, 'type'); + + $this->pushToken(Token::NAME_TYPE, $hintMatch[2]); + $this->moveCursor($hintMatch[2]); + + $this->moveCursor($hintMatch[3]); + + $this->pushToken(Token::NAME_TYPE, $hintMatch[4]); + $this->moveCursor($hintMatch[4]); + + $this->pushToken(Token::BLOCK_END_TYPE); + $this->moveCursor($hintMatch[5]); + } else { + $this->moveCursor($text); + } } private function lexString(): void diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index 29a446b881b..85adc55470c 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -13,8 +13,13 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Environment; use Twig\Extension\SandboxExtension; use Twig\Template; +use Twig\TypeHint\ArrayType; +use Twig\TypeHint\ObjectType; +use Twig\TypeHint\TypeInterface; +use Twig\TypeHint\UnionType; class GetAttrExpression extends AbstractExpression { @@ -32,6 +37,33 @@ public function compile(Compiler $compiler): void { $env = $compiler->getEnvironment(); + if ($this->getNode('attribute') instanceof ConstantExpression) { + $type = null; + + if ($this->getNode('node')->hasAttribute('typeHint')) { + $type = $this->getNode('node')->getAttribute('typeHint'); + } + + if ($type instanceof TypeInterface) { + $sourceCompiler = $this->createNodeSourceCompiler(); + $accessCompiler = $this->createAccessCompiler($type, $env); + + if (true || $accessCompiler['condition'] === null) { + $accessCompiler['accessor']($compiler, $sourceCompiler); + } else { + $compiler->raw('('); + $accessCompiler['condition']($compiler, $sourceCompiler); + $compiler->raw(' ? '); + $accessCompiler['accessor']($compiler, $sourceCompiler); + $compiler->raw(' : '); + $this->createGuessingAccessCompiler($env->hasExtension(SandboxExtension::class))['accessor']($compiler, $sourceCompiler); + $compiler->raw(')'); + } + + return; + } + } + // optimize array calls if ( $this->getAttribute('optimizable') @@ -57,31 +89,264 @@ public function compile(Compiler $compiler): void return; } - $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); + $this->createGuessingAccessCompiler($env->hasExtension(SandboxExtension::class))['accessor']($compiler, $this->createNodeSourceCompiler()); + } - if ($this->getAttribute('ignore_strict_check')) { - $this->getNode('node')->setAttribute('ignore_strict_check', true); + /** + * @return array{ + * condition: \Closure(Compiler, \Closure(Compiler): void): void|null, + * accessor: \Closure(Compiler, \Closure(Compiler): void): void + * } + */ + private function createAccessCompiler(TypeInterface $type, Environment $env): array + { + if ($type instanceof UnionType) { + return $this->createUnionAccessCompiler($type, $env); } - $compiler - ->subcompile($this->getNode('node')) - ->raw(', ') - ->subcompile($this->getNode('attribute')) - ; + if ($type instanceof ArrayType) { + return $this->createArrayAccessCompiler(); + } + + if ($type instanceof ObjectType && $this->getNode('attribute') instanceof ConstantExpression) { + $attributeName = $this->getNode('attribute')->getAttribute('value'); + + if ($type->getPropertyType($attributeName) !== null) { + return $this->createObjectPropertyAccessCompiler($type, $attributeName); + } + + /** Keep similar to @see \Twig\TypeHint\ObjectType::getAttributeType */ + $methodNames = [ + $attributeName, + 'get' . $attributeName, + 'is' . $attributeName, + 'has' . $attributeName, + ]; + + foreach ($methodNames as $methodName) { + if ($type->getMethodType($methodName) === null) { + continue; + } + + return $this->createObjectMethodAccessCompiler($type, $methodName); + } + } + + return $this->createGuessingAccessCompiler($env->hasExtension(SandboxExtension::class)); + } + + /** + * @return array{ + * condition: null, + * accessor: \Closure(Compiler, \Closure(Compiler): void): void + * } + */ + private function createGuessingAccessCompiler(bool $isSandboxed): array + { + return [ + 'condition' => null, + 'accessor' => function (Compiler $compiler, \Closure $sourceCompiler) use ($isSandboxed): void { + $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); + + if ($this->getAttribute('ignore_strict_check')) { + $this->getNode('node')->setAttribute('ignore_strict_check', true); + } + + $sourceCompiler($compiler); + + $compiler + ->raw(', ') + ->subcompile($this->getNode('attribute')) + ; + + if ($this->hasNode('arguments')) { + $compiler->raw(', ')->subcompile($this->getNode('arguments')); + } else { + $compiler->raw(', []'); + } + + $compiler->raw(', ') + ->repr($this->getAttribute('type')) + ->raw(', ')->repr($this->getAttribute('is_defined_test')) + ->raw(', ')->repr($this->getAttribute('ignore_strict_check')) + ->raw(', ')->repr($isSandboxed) + ->raw(', ')->repr($this->getNode('node')->getTemplateLine()) + ->raw(')') + ; + }, + ]; + } + + /** + * @return array{ + * condition: \Closure(Compiler, \Closure(Compiler): void): void, + * accessor: \Closure(Compiler, \Closure(Compiler): void): void + * } + */ + private function createObjectMethodAccessCompiler(ObjectType $type, string $attributeName): array + { + return [ + 'condition' => function (Compiler $compiler, \Closure $sourceCompiler) use ($type): void { + $sourceCompiler($compiler); + $compiler->raw(' instanceof \\')->raw($type->getType()); + }, + 'accessor' => function (Compiler $compiler, \Closure $sourceCompiler) use ($attributeName): void { + $compiler->raw('('); + + $sourceCompiler($compiler); + + $compiler->raw('?->')->raw($attributeName)->raw('('); - if ($this->hasNode('arguments')) { - $compiler->raw(', ')->subcompile($this->getNode('arguments')); - } else { - $compiler->raw(', []'); + if ($this->hasNode('arguments') && $this->getNode('arguments') instanceof ArrayExpression && $this->getNode('arguments')->count() > 0) { + for ($argIndex = 0; $argIndex < $this->getNode('arguments')->count(); $argIndex += 2) { + if ($argIndex > 0) { + $compiler->raw(', '); + } + + $compiler->subcompile($this->getNode('arguments')->getNode($argIndex + 1)); + } + } + + $compiler->raw('))'); + }, + ]; + } + + /** + * @return array{ + * condition: \Closure(Compiler, \Closure(Compiler): void): void, + * accessor: \Closure(Compiler, \Closure(Compiler): void): void + * } + */ + private function createObjectPropertyAccessCompiler(ObjectType $type, string $attributeName): array + { + return [ + 'condition' => function (Compiler $compiler, \Closure $sourceCompiler) use ($type): void { + $sourceCompiler($compiler); + $compiler->raw(' instanceof \\')->raw($type->getType()); + }, + 'accessor' => function (Compiler $compiler, \Closure $sourceCompiler) use ($attributeName): void { + $sourceCompiler($compiler); + $compiler + ->raw('?->') + ->raw($attributeName); + }, + ]; + } + + /** + * @return array{ + * condition: null, + * accessor: \Closure(Compiler, \Closure(Compiler): void): void + * } + */ + private function createUnionAccessCompiler(UnionType $type, Environment $env): array + { + $accessors = []; + + foreach ($type->getTypes() as $innerType) { + $accessors[] = $this->createAccessCompiler($innerType, $env); } - $compiler->raw(', ') - ->repr($this->getAttribute('type')) - ->raw(', ')->repr($this->getAttribute('is_defined_test')) - ->raw(', ')->repr($this->getAttribute('ignore_strict_check')) - ->raw(', ')->repr($env->hasExtension(SandboxExtension::class)) - ->raw(', ')->repr($this->getNode('node')->getTemplateLine()) - ->raw(')') - ; + return [ + 'condition' => null, + 'accessor' => function (Compiler $compiler, \Closure $sourceCompiler) use ($accessors) { + $compiler->raw('match (['); + $compiler->indent(); + $sourceCompiler($compiler); + $compiler->raw(", true][1]) {\n"); + + foreach ($accessors as $accessor) { + if ($accessor['condition'] === null) { + $compiler->raw('default'); + } else { + $accessor['condition']($compiler, $sourceCompiler); + } + + $compiler->raw(' => '); + $accessor['accessor']($compiler, $sourceCompiler); + $compiler->raw(";\n"); + } + + $compiler->outdent(); + $compiler->raw('}'); + } + ]; + } + + /** + * @return array{ + * condition: \Closure(Compiler, \Closure(Compiler): void): void, + * accessor: \Closure(Compiler, \Closure(Compiler): void): void + * } + */ + private function createArrayAccessCompiler(): array + { + return [ + 'condition' => function (Compiler $compiler, \Closure $sourceCompiler): void { + $compiler->raw('(\is_array('); + $sourceCompiler($compiler); + $compiler->raw(') || '); + $sourceCompiler($compiler); + $compiler->raw(' instanceof \\ArrayAccess)'); + }, + 'accessor' => function (Compiler $compiler, \Closure $sourceCompiler): void { + $compiler->raw('('); + $sourceCompiler($compiler); + $compiler + ->raw('[') + ->subcompile($this->getNode('attribute')) + ->raw('] ?? null)'); + }, + ]; + } + + /** + * @return \Closure(Compiler): void + */ + private function createAutoInlineSourceCompiler(): \Closure + { + $varName = null; + $sourceCompiler = $this->createNodeSourceCompiler(); + + return function (Compiler $compiler) use (&$varName, &$sourceCompiler): void { + if ($varName === null) { + $varName = $compiler->getVarName(); + $newSourceCompiler = $this->createVarNameSourceCompiler($varName); + + $compiler->raw('('); + $newSourceCompiler($compiler); + $compiler->raw(' = '); + $sourceCompiler($compiler); + $compiler->raw(')'); + + $sourceCompiler = $newSourceCompiler; + } else { + $sourceCompiler($compiler); + } + }; + } + + /** + * @return \Closure(Compiler): void + */ + private function createNodeSourceCompiler(): \Closure + { + return function (Compiler $compiler): void { + $compiler->subcompile($this->getNode('node')); + }; + } + + /** + * @return \Closure(Compiler): void + */ + private function createVarNameSourceCompiler(string $varName): \Closure + { + return function (Compiler $compiler) use ($varName): void { + $compiler + ->raw('$') + ->raw($varName) + ; + }; } } diff --git a/src/Node/TypeHintNode.php b/src/Node/TypeHintNode.php new file mode 100644 index 00000000000..0453f1d0090 --- /dev/null +++ b/src/Node/TypeHintNode.php @@ -0,0 +1,32 @@ + + */ +class TypeHintNode extends Node +{ + public function __construct(string $name, string $type, int $lineno, string $tag = null) + { + parent::__construct([], ['name' => $name, 'type' => $type], $lineno, $tag); + } + + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + } +} diff --git a/src/Node/WithNode.php b/src/Node/WithNode.php index 9b8c5788466..fb58d4d98c7 100644 --- a/src/Node/WithNode.php +++ b/src/Node/WithNode.php @@ -24,11 +24,14 @@ class WithNode extends Node { public function __construct(Node $body, ?Node $variables, bool $only, int $lineno, ?string $tag = null) { - $nodes = ['body' => $body]; + $nodes = []; + if (null !== $variables) { $nodes['variables'] = $variables; } + $nodes['body'] = $body; + parent::__construct($nodes, ['only' => $only], $lineno, $tag); } diff --git a/src/NodeVisitor/TypeEvaluateNodeVisitor.php b/src/NodeVisitor/TypeEvaluateNodeVisitor.php new file mode 100644 index 00000000000..c5dbdd90d63 --- /dev/null +++ b/src/NodeVisitor/TypeEvaluateNodeVisitor.php @@ -0,0 +1,443 @@ + + * + * @internal + */ +final class TypeEvaluateNodeVisitor implements NodeVisitorInterface +{ + public function enterNode(Node $node, Environment $env): Node + { + if ($node instanceof WithNode) { + if ($node->hasNode('variables')) { + // we need to store the parent, so we can assign notes to the parent, after the variables' sibling nodes are entered + $node->getNode('variables')->setAttribute('setKeyValuesAsTypeHints', $node->getAttribute('only')); + } + } + + if ($node instanceof TypeHintNode) { + $env->getTypeHintStack()->addVariableType($node->getAttribute('name'), TypeFactory::createTypeFromText($node->getAttribute('type'))); + } + + if ($node instanceof BodyNode) { + $env->getTypeHintStack()->pushMinorStack(); + } + + return $node; + } + + public function leaveNode(Node $node, Environment $env): ?Node + { + $possibleTypes = []; + + foreach ($this->getPossibleTypes($node, $env) as $possibleType) { + if (!$possibleType instanceof TypeInterface && $possibleType !== null) { + $possibleType = TypeFactory::createTypeFromText((string) $possibleType); + } + + $possibleTypes[] = $possibleType; + } + + if ($possibleTypes !== []) { + if (\count($possibleTypes) === 1) { + $node->setAttribute('typeHint', $possibleTypes[0]); + } else { + $node->setAttribute('typeHint', new UnionType($possibleTypes)); + } + } + + if ($node instanceof SetNode) { + /** @var array $typedVariables */ + $typedVariables = []; + + // capture is always string + if ($node->getAttribute('capture')) { + $stringType = TypeFactory::createTypeFromText('string'); + /** @var Node $innerNameNode */ + foreach ($node->getNode('names') as $innerNameNode) { + $typedVariables[$innerNameNode->getAttribute('name')] = $stringType; + } + } else { + // TODO push state + /** @var AssignNameExpression $innerNameNode */ + foreach ($node->getNode('names') as $nameIndex => $innerNameNode) { + $typedVariables[$innerNameNode->getAttribute('name')] = $node->getNode('values')->getNode($nameIndex)->getAttribute('typeHint'); + } + } + + if ($typedVariables !== []) { + $node->setAttribute('typeHint', new ArrayType($typedVariables)); + + foreach ($typedVariables as $typedVariableName => $typedVariableTypes) { + $env->getTypeHintStack()->addVariableType($typedVariableName, $typedVariableTypes); + } + } + } + + if ($node instanceof ArrayExpression) { + $typedVariables = []; + + for ($arrayIterator = $node->count() - 2; $arrayIterator >= 0; $arrayIterator -= 2) { + $nameNode = $node->getNode($arrayIterator); + $valueNode = $node->getNode($arrayIterator + 1); + + if ($nameNode instanceof ConstantExpression) { + $varName = $nameNode->getAttribute('value'); + + if ($valueNode->hasAttribute('typeHint')) { + $typedVariables[$varName] = $valueNode->getAttribute('typeHint'); + } + } + } + + if ($typedVariables !== []) { + $node->setAttribute('typeHint', new ArrayType($typedVariables)); + } + } + + // this is the variables child node from WithNode and therefore these are types to note down + if ($node->hasAttribute('setKeyValuesAsTypeHints')) { + $only = $node->getAttribute('setKeyValuesAsTypeHints'); + + if ($node instanceof ArrayExpression && $node->hasAttribute('typeHint') && $node->getAttribute('typeHint') instanceof ArrayType) { + if ($only) { + $env->getTypeHintStack()->pushMajorStack(); + } else { + $env->getTypeHintStack()->pushMinorStack(); + } + + foreach ($node->getAttribute('typeHint')->getAttributes() as $typedVariableName => $typedVariableTypes) { + $env->getTypeHintStack()->addVariableType($typedVariableName, $typedVariableTypes); + } + } + + $node->removeAttribute('setKeyValuesAsTypeHints'); + } + + if ($node instanceof WithNode) { + if ($node->getAttribute('only')) { + $env->getTypeHintStack()->popMajorStack(); + } else { + $env->getTypeHintStack()->popMinorStack(); + } + } + + if ($node instanceof BodyNode) { + $env->getTypeHintStack()->popMinorStack(); + } + + return $node; + } + + public function getPriority(): int + { + return 10; + } + + private function getPossibleTypes(Node $node, Environment $env): iterable + { + if ($node instanceof AutoEscapeNode) { + yield 'string'; + } + + if ($node instanceof ConstantExpression) { + yield from $this->getPossibleConstantExpressionTypes($node); + } + + if ($node instanceof GetAttrExpression) { + if ($node->getNode('attribute') instanceof ConstantExpression) { + $attributeName = $node->getNode('attribute')->getAttribute('value'); + $typeHint = null; + + if ($node->getNode('node')->hasAttribute('typeHint')) { + $typeHint = $node->getNode('node')->getAttribute('typeHint'); + } elseif ($node->getNode('node') instanceof NameExpression) { + $variableName = $node->getNode('node')->getAttribute('name'); + $typeHint = $env->getTypeHintStack()->getVariableType($variableName); + } + + if ($typeHint instanceof TypeInterface) { + $variableType = $typeHint->getAttributeType($attributeName); + + if ($variableType !== null) { + yield $variableType; + } + } + } + } + + if ($node instanceof MacroNode) { + yield 'string'; + } + + if ($node instanceof ArrayExpression) { // VariadicExpression + yield 'array'; + yield '\\ArrayAccess'; + } + + if ($node instanceof BlockReferenceExpression) { + yield 'string'; + } + + if ($node instanceof ParentExpression) { + yield 'string'; + } + + if ($node instanceof NameExpression && !$node instanceof AssignNameExpression) { + $result = $env->getTypeHintStack()->getVariableType($node->getAttribute('name')); + + if ($result !== null) { + yield $result; + } + } + + if ($node instanceof TestExpression) { + yield 'boolean'; + } + + if ($node instanceof NotUnary) { + yield 'boolean'; + } + + if ($node instanceof NegUnary) { + yield 'integer'; + yield 'float'; + } + + if ($node instanceof PosUnary) { + yield 'integer'; + yield 'float'; + } + + yield from $this->getPossibleTypesOfBinaryExpression($node); + + if (\get_class($node) === Node::class) { + /** @var Node $innerNode */ + foreach ($node as $innerNode) { + if (!$innerNode->hasAttribute('typeHint')) { + continue; + } + + $typeHint = $innerNode->getAttribute('typeHint'); + + if ($typeHint !== null) { + yield $typeHint; + } + } + } + } + + /** + * @return iterable + */ + private function getPossibleConstantExpressionTypes(ConstantExpression $node): iterable + { + $nodeValue = $node->getAttribute('value'); + + if (!\is_object($nodeValue)) { + $phpType = \gettype($nodeValue); + + if ($phpType === 'double') { + yield 'float'; + } elseif ($phpType === 'NULL') { + yield 'float'; + } else { + yield $phpType; + } + } else { + yield '\\' . \get_class($nodeValue); + } + } + + /** + * @return iterable + */ + private function getPossibleTypesOfBinaryExpression(Node $node): iterable + { + if ($node instanceof AddBinary) { + yield 'integer'; + yield 'float'; + } + + if ($node instanceof AndBinary) { + yield 'boolean'; + } + + if ($node instanceof BitwiseAndBinary) { + yield 'integer'; + } + + if ($node instanceof BitwiseOrBinary) { + yield 'integer'; + } + + if ($node instanceof BitwiseXorBinary) { + yield 'integer'; + } + + if ($node instanceof ConcatBinary) { + yield 'string'; + } + + if ($node instanceof DivBinary) { + yield 'integer'; + yield 'float'; + } + + if ($node instanceof EndsWithBinary) { + yield 'boolean'; + } + + if ($node instanceof EqualBinary) { + yield 'boolean'; + } + + if ($node instanceof FloorDivBinary) { + yield 'integer'; + } + + if ($node instanceof GreaterBinary) { + yield 'boolean'; + } + + if ($node instanceof GreaterEqualBinary) { + yield 'boolean'; + } + + if ($node instanceof HasEveryBinary) { + yield 'boolean'; + } + + if ($node instanceof HasSomeBinary) { + yield 'boolean'; + } + + if ($node instanceof InBinary) { + yield 'boolean'; + } + + if ($node instanceof LessBinary) { + yield 'boolean'; + } + + if ($node instanceof LessEqualBinary) { + yield 'boolean'; + } + + if ($node instanceof MatchesBinary) { + yield 'boolean'; + } + + if ($node instanceof ModBinary) { + yield 'integer'; + } + + if ($node instanceof MulBinary) { + yield 'integer'; + yield 'float'; + } + + if ($node instanceof NotEqualBinary) { + yield 'boolean'; + } + + if ($node instanceof NotInBinary) { + yield 'boolean'; + } + + if ($node instanceof OrBinary) { + yield 'boolean'; + } + + if ($node instanceof PowerBinary) { + yield 'integer'; + yield 'float'; + } + + if ($node instanceof RangeBinary) { + yield 'array'; + yield '\\ArrayAccess'; + } + + if ($node instanceof SpaceshipBinary) { + yield 'integer'; + } + + if ($node instanceof StartsWithBinary) { + yield 'boolean'; + } + + if ($node instanceof SubBinary) { + yield 'integer'; + yield 'float'; + } + } +} diff --git a/src/TokenParser/TypeHintTokenParser.php b/src/TokenParser/TypeHintTokenParser.php new file mode 100644 index 00000000000..1904e625d20 --- /dev/null +++ b/src/TokenParser/TypeHintTokenParser.php @@ -0,0 +1,69 @@ + + * @internal + */ +final class TypeHintTokenParser extends AbstractTokenParser +{ + public function parse(Token $token): Node + { + $stream = $this->parser->getStream(); + $name = $this->parser->getExpressionParser()->parseExpression(); + + if (!$name instanceof NameExpression) { + throw new SyntaxError('A type hint must refer to a variable with a constant name', $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } + + $type = $this->parser->getExpressionParser()->parseExpression(); + $typeValue = null; + + if ($type instanceof NameExpression) { + $typeValue = $type->getAttribute('name'); + } + + if ($type instanceof ConstantExpression) { + $typeValue = $type->getAttribute('value'); + } + + if (!\is_string($typeValue)) { + throw new SyntaxError('A type hint must refer to a type with a constant name', $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } + + $this->parser->getStream()->expect(/* Token::BLOCK_END_TYPE */ 3); + + return new TypeHintNode( + $name->getAttribute('name'), + $typeValue, + $token->getLine(), + $this->getTag() + ); + } + + public function getTag(): string + { + return 'type'; + } +} diff --git a/src/TypeHint/ArrayType.php b/src/TypeHint/ArrayType.php new file mode 100644 index 00000000000..af0a5618060 --- /dev/null +++ b/src/TypeHint/ArrayType.php @@ -0,0 +1,49 @@ + + */ +class ArrayType extends Type +{ + /** + * @var array + */ + private array $attributes; + + /** + * @param array $attributes + */ + public function __construct(array $attributes) + { + parent::__construct('array'); + $this->attributes = $attributes; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttributeType(string|int $attribute): ?TypeInterface + { + return $this->attributes[$attribute] ?? null; + } +} diff --git a/src/TypeHint/ContextStack.php b/src/TypeHint/ContextStack.php new file mode 100644 index 00000000000..8a2e3bd44f7 --- /dev/null +++ b/src/TypeHint/ContextStack.php @@ -0,0 +1,68 @@ + + */ +class ContextStack +{ + /** + * First/major layer non-sharing stacks (with-node e.g. tagged as only) + * Second/minor layer for sharing stacks (with-node e.g. not tagged as only) + * + * @var list>>> + */ + private array $variables = [[]]; + + public function getVariableType(string $name): ?TypeInterface + { + $result = []; + + foreach ($this->variables[0] as $types) { + foreach ($types[$name] ?? [] as $type) { + $result[] = $type; + } + } + + return TypeFactory::createTypeFromCollection($result); + } + + public function addVariableType(string $name, TypeInterface $type): void + { + $this->variables[0][0][$name][] = $type; + } + + public function pushMajorStack(): void + { + \array_unshift($this->variables, []); + } + + public function popMajorStack(): void + { + \array_shift($this->variables); + } + + public function pushMinorStack(): void + { + \array_unshift($this->variables[0], []); + } + + public function popMinorStack(): void + { + \array_shift($this->variables[0]); + } +} diff --git a/src/TypeHint/ObjectType.php b/src/TypeHint/ObjectType.php new file mode 100644 index 00000000000..b0e555381e1 --- /dev/null +++ b/src/TypeHint/ObjectType.php @@ -0,0 +1,116 @@ + + */ +class ObjectType extends Type +{ + private \ReflectionClass $reflectionClass; + + /** + * @var array + */ + private array $properties = []; + + /** + * @var array + */ + private array $methods = []; + + public function __construct(\ReflectionClass $reflectionClass) + { + parent::__construct($reflectionClass->getName()); + $this->reflectionClass = $reflectionClass; + } + + public function getAttributeType(string|int $attribute): ?TypeInterface + { + return $this->getPropertyType((string) $attribute) + ?? $this->getMethodType((string) $attribute) + ?? $this->getMethodType('get' . $attribute) + ?? $this->getMethodType('is' . $attribute) + ?? $this->getMethodType('has' . $attribute); + } + + public function getPropertyType(string $name): ?TypeInterface + { + if (\array_key_exists($name, $this->properties)) { + return $this->properties[$name]; + } + + return $this->properties[$name] = $this->createPropertyType($name); + } + + private function createPropertyType(string $name): ?TypeInterface + { + if (!$this->reflectionClass->hasProperty($name)) { + return null; + } + + try { + $property = $this->reflectionClass->getProperty($name); + + if (!$property->isPublic()) { + return null; + } + + return $this->createType($property->getType()); + } catch (\Throwable) { + return null; + } + } + + public function getMethodType(string $name): ?TypeInterface + { + if (\array_key_exists($name, $this->methods)) { + return $this->methods[$name]; + } + + return $this->methods[$name] = $this->createMethodType($name); + } + + private function createMethodType(string $name): ?TypeInterface + { + if (!$this->reflectionClass->hasMethod($name)) { + $this->methods[$name] = null; + + return null; + } + + try { + $method = $this->reflectionClass->getMethod($name); + + if (!$method->isPublic()) { + return null; + } + + return $this->createType($method->getReturnType()); + } catch (\Throwable) { + return null; + } + } + + private function createType(?\ReflectionType $type): ?TypeInterface + { + if ($type === null) { + return null; + } + + return TypeFactory::createTypeFromText((string) $type); + } +} diff --git a/src/TypeHint/Type.php b/src/TypeHint/Type.php new file mode 100644 index 00000000000..11cf2549fcf --- /dev/null +++ b/src/TypeHint/Type.php @@ -0,0 +1,39 @@ + + */ +class Type implements TypeInterface +{ + private string $type; + + public function __construct(string $type) + { + $this->type = $type; + } + + public function getType(): string + { + return $this->type; + } + + public function getAttributeType(string|int $attribute): ?TypeInterface + { + return null; + } +} diff --git a/src/TypeHint/TypeFactory.php b/src/TypeHint/TypeFactory.php new file mode 100644 index 00000000000..1fdaf97dc20 --- /dev/null +++ b/src/TypeHint/TypeFactory.php @@ -0,0 +1,67 @@ + + */ +abstract class TypeFactory +{ + private static array $plainTypeCache = []; + + private static array $objectTypeCache = []; + + public static function createTypeFromText(string $type): ?TypeInterface + { + $types = []; + + foreach (\explode('|', $type) as $propertyType) { + if ($propertyType === '') { + continue; + } + + try { + $types[] = self::createObjectType(\ltrim($propertyType, '\\')); + } catch (\Throwable) { + $types[] = self::createPlainType($propertyType); + } + } + + return static::createTypeFromCollection($types); + } + + public static function createTypeFromCollection(array $types): ?TypeInterface + { + if ($types === []) { + return null; + } + + return \count($types) === 1 ? $types[0] : new UnionType($types); + } + + private static function createPlainType(string $type): Type + { + return self::$plainTypeCache[$type] ??= new Type($type); + } + + /** + * @throws \ReflectionException + */ + private static function createObjectType(string $class): ObjectType + { + return self::$objectTypeCache[$class] ??= new ObjectType(new \ReflectionClass($class)); + } +} diff --git a/src/TypeHint/TypeInterface.php b/src/TypeHint/TypeInterface.php new file mode 100644 index 00000000000..6a69a52b784 --- /dev/null +++ b/src/TypeHint/TypeInterface.php @@ -0,0 +1,24 @@ + + */ +interface TypeInterface +{ + public function getAttributeType(string|int $attribute): ?TypeInterface; +} diff --git a/src/TypeHint/UnionType.php b/src/TypeHint/UnionType.php new file mode 100644 index 00000000000..f6c9d33bbf3 --- /dev/null +++ b/src/TypeHint/UnionType.php @@ -0,0 +1,70 @@ + + */ +final class UnionType implements TypeInterface +{ + /** + * @var list + */ + private array $types; + + /** + * @param list $types + */ + public function __construct(array $types) + { + $items = []; + + foreach ($types as $type) { + if ($type instanceof UnionType) { + foreach ($type->getTypes() as $innerType) { + $items[] = $innerType; + } + } else { + $items[] = $type; + } + } + + $this->types = $items; + } + + /** + * @return list + */ + public function getTypes(): array + { + return $this->types; + } + + public function getAttributeType(string|int $attribute): ?TypeInterface + { + $result = []; + + foreach ($this->types as $type) { + $attributeType = $type->getAttributeType($attribute); + + if ($attributeType !== null) { + $result[] = $attributeType; + } + } + + return TypeFactory::createTypeFromCollection($result); + } +} diff --git a/tests/Fixtures/tags/type/comment.type-hint.test b/tests/Fixtures/tags/type/comment.type-hint.test new file mode 100644 index 00000000000..46808d99428 --- /dev/null +++ b/tests/Fixtures/tags/type/comment.type-hint.test @@ -0,0 +1,11 @@ +--TEST-- +Add PHP types to variables +--TEMPLATE-- +{# @var interval \DateInterval #} +{{ interval.s }} +--DATA-- +return [ + 'interval' => new \DateInterval('PT6S'), +] +--EXPECT-- +6 diff --git a/tests/Fixtures/tags/type/tag.type-hint.test b/tests/Fixtures/tags/type/tag.type-hint.test new file mode 100644 index 00000000000..a93f639bdb8 --- /dev/null +++ b/tests/Fixtures/tags/type/tag.type-hint.test @@ -0,0 +1,11 @@ +--TEST-- +Add PHP types to variables +--TEMPLATE-- +{% type interval "\\DateInterval" %} +{{ interval.s }} +--DATA-- +return [ + 'interval' => new \DateInterval('PT6S'), +] +--EXPECT-- +6 diff --git a/tests/LexerTest.php b/tests/LexerTest.php index ad62c22acfb..2d390c5ef6d 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php @@ -131,6 +131,32 @@ public function testLongComments() $this->addToAssertionCount(1); } + public function testTypeHintFromComment() + { + $template = '{# @var interval \DateInterval|null #}'; + + $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::BLOCK_START_TYPE); + static::assertSame('type', $stream->expect(Token::NAME_TYPE)->getValue()); + static::assertSame('interval', $stream->expect(Token::NAME_TYPE)->getValue()); + static::assertSame('\DateInterval|null', $stream->expect(Token::NAME_TYPE)->getValue()); + $stream->expect(Token::BLOCK_END_TYPE); + } + + public function testTypeHintFromBlock() + { + $template = '{% type interval "\\\\DateInterval|null" %}'; + + $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::BLOCK_START_TYPE); + static::assertSame('type', $stream->expect(Token::NAME_TYPE)->getValue()); + static::assertSame('interval', $stream->expect(Token::NAME_TYPE)->getValue()); + static::assertSame('\\DateInterval|null', $stream->expect(Token::STRING_TYPE)->getValue()); + $stream->expect(Token::BLOCK_END_TYPE); + } + public function testLongVerbatim() { $template = '{% verbatim %}'.str_repeat('*', 100000).'{% endverbatim %}'; diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index c76fb3992d5..19942ed2f62 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -11,10 +11,13 @@ * file that was distributed with this source code. */ +use Twig\Extension\TypeOptimizerExtension; +use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\GetAttrExpression; use Twig\Node\Expression\NameExpression; +use Twig\Source; use Twig\Template; use Twig\Test\NodeTestCase; @@ -55,6 +58,151 @@ public function getTests() $node = new GetAttrExpression($expr, $attr, $args, Template::METHOD_CALL, 1); $tests[] = [$node, sprintf('%s%s, "bar", [%s, "bar"], "method", false, false, false, 1)', $this->getAttributeGetter(), $this->getVariableGetter('foo', 1), $this->getVariableGetter('foo'))]; + $optimizedEnv = $this->getEnvironment(); + $optimizedEnv->setExtensions([new TypeOptimizerExtension()]); + $optimizedEnv->setLoader($this->createMock(LoaderInterface::class)); + + $tests[] = [ + $optimizedEnv->parse( + $optimizedEnv->tokenize( + new Source('{{ ({ bar: { baz: 42 } }).bar.baz|raw }}', 'index.twig') + ) + )->getNode('body'), + <<<'PHP' +// line 1 +echo ((["bar" => ["baz" => 42]]["bar"] ?? null)["baz"] ?? null); +PHP, + $optimizedEnv, + ]; + + $tests[] = [ + $optimizedEnv->parse( + $optimizedEnv->tokenize( + new Source("{% set foo = { bar: { baz: 42 } } %}\n{{ foo.bar.baz|raw }}", 'index.twig') + ) + )->getNode('body'), + <<<'PHP' +// line 1 +$context["foo"] = ["bar" => ["baz" => 42]]; +// line 2 +echo ((($context["foo"] ?? null)["bar"] ?? null)["baz"] ?? null); +PHP, + $optimizedEnv, + ]; + + $tests[] = [ + $optimizedEnv->parse($optimizedEnv->tokenize(new Source(<<<'TWIG' +{% type obj "\\Twig\\Tests\\Node\\Expression\\ClassWithPublicProperty" %} +{{ obj.name|raw }} +TWIG, 'index.twig')))->getNode('body'), + <<<'PHP' +// line 1 +// line 2 +echo ($context["obj"] ?? null)?->name; +PHP, + $optimizedEnv, + ]; + + $tests[] = [ + $optimizedEnv->parse($optimizedEnv->tokenize(new Source(<<<'TWIG' +{% type obj "\\Twig\\Tests\\Node\\Expression\\ClassWithPublicGetter" %} +{{ obj.name|raw }} +TWIG, 'index.twig')))->getNode('body'), + <<<'PHP' +// line 1 +// line 2 +echo (($context["obj"] ?? null)?->getname()); +PHP, + $optimizedEnv, + ]; + + $tests[] = [ + $optimizedEnv->parse($optimizedEnv->tokenize(new Source(<<<'TWIG' +{% type obj "\\Twig\\Tests\\Node\\Expression\\ClassWithPublicFactory" %} +{{ obj.byName("foobar")|raw }} +TWIG, 'index.twig')))->getNode('body'), + <<<'PHP' +// line 1 +// line 2 +echo (($context["obj"] ?? null)?->byName("foobar")); +PHP, + $optimizedEnv, + ]; + + $tests[] = [ + $optimizedEnv->parse($optimizedEnv->tokenize(new Source(<<<'TWIG' +{% type obj "\\Twig\\Tests\\Node\\Expression\\ClassWithPublicComplexGetter" %} +{{ obj.instance.name|raw }} +TWIG, 'index.twig')))->getNode('body'), + <<<'PHP' +// line 1 +// line 2 +echo ((($context["obj"] ?? null)?->getinstance())?->getname()); +PHP, + $optimizedEnv, + ]; + + $tests[] = [ + $optimizedEnv->parse($optimizedEnv->tokenize(new Source(<<<'TWIG' +{% type obj "\\Twig\\Tests\\Node\\Expression\\ClassWithPublicProperty|\\Twig\\Tests\\Node\\Expression\\ClassWithPublicGetter" %} +{{ obj.name|raw }} +TWIG, 'index.twig')))->getNode('body'), + <<<'PHP' +// line 1 +// line 2 +echo match ([($context["obj"] ?? null), true][1]) { +($context["obj"] ?? null) instanceof \Twig\Tests\Node\Expression\ClassWithPublicProperty => ($context["obj"] ?? null)?->name; +($context["obj"] ?? null) instanceof \Twig\Tests\Node\Expression\ClassWithPublicGetter => (($context["obj"] ?? null)?->getname()); +}; +PHP, + $optimizedEnv, + ]; + return $tests; } } + +class ClassWithPublicProperty +{ + public function __construct( + public string $name + ) + { + } +} + +class ClassWithPublicGetter +{ + public function __construct( + private string $name + ) + { + } + + public function getName(): string + { + return $this->name; + } +} + +class ClassWithPublicFactory +{ + public function byName(string $name): string + { + return $name; + } +} + +class ClassWithPublicComplexGetter +{ + public function __construct( + private string $name + ) + { + } + + public function getInstance(): ClassWithPublicGetter + { + return new ClassWithPublicGetter($this->name); + } +} diff --git a/tests/Node/TypeHintTest.php b/tests/Node/TypeHintTest.php new file mode 100644 index 00000000000..6ec47aff1d6 --- /dev/null +++ b/tests/Node/TypeHintTest.php @@ -0,0 +1,36 @@ +assertEquals('interval', $node->getAttribute('name')); + $this->assertEquals('\DateInterval|string', $node->getAttribute('type')); + } + + public function getTests() + { + $tests = []; + + $node = new TypeHintNode('interval', '\DateInterval|string', 1); + $tests[] = [$node, "// line 1"]; + + return $tests; + } +} diff --git a/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php b/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php new file mode 100644 index 00000000000..8d465e8a2bd --- /dev/null +++ b/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php @@ -0,0 +1,125 @@ +createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new TypeOptimizerExtension()); + + $stream = $env->parse($env->tokenize(new Source('{{ block("foo") }}', 'index'))); + + $node = $stream->getNode('body')->getNode(0); + + $this->assertInstanceOf(BlockReferenceExpression::class, $node); + $this->assertSame('string', $node->getAttribute('typeHint')->getType()); + } + + public function testStringTypeOnConcat(): void + { + $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new TypeOptimizerExtension()); + + $stream = $env->parse($env->tokenize(new Source('{{ "foo" ~ "bar" }}', 'index'))); + + $node = $stream->getNode('body')->getNode(0); + + $this->assertInstanceOf(PrintNode::class, $node); + + $expr = $node->getNode('expr'); + + $this->assertInstanceOf(ConcatBinary::class, $expr); + $this->assertSame('string', $expr->getAttribute('typeHint')->getType()); + } + + public function testNumericTypeOnAddition(): void + { + $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new TypeOptimizerExtension()); + + $stream = $env->parse($env->tokenize(new Source('{{ 1 + 3 }}', 'index'))); + + $node = $stream->getNode('body')->getNode(0); + + $this->assertInstanceOf(PrintNode::class, $node); + + $expr = $node->getNode('expr'); + + $this->assertInstanceOf(AddBinary::class, $expr); + + $unionType = $expr->getAttribute('typeHint'); + + $this->assertInstanceOf(UnionType::class, $unionType); + $this->assertEqualsCanonicalizing(['integer', 'float'], [$unionType->getTypes()[0]->getType(), $unionType->getTypes()[1]->getType()]); + } + + public function testSetVariableIsAssignedArrayObject(): void + { + $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new TypeOptimizerExtension()); + + $stream = $env->parse($env->tokenize(new Source('{% set foo = { bar: 42 } %}', 'index'))); + + $node = $stream->getNode('body')->getNode(0); + $type = $node->getAttribute('typeHint'); + + $this->assertInstanceOf(SetNode::class, $node); + $this->assertInstanceOf(ArrayType::class, $type); + + $fooType = $type->getAttributeType('foo'); + + $this->assertInstanceOf(ArrayType::class, $fooType); + + $barType = $fooType->getAttributeType('bar'); + + $this->assertInstanceOf(Type::class, $barType); + $this->assertSame('integer', $barType->getType()); + } + + public function testArrayExpressionReferencingOtherVariable(): void + { + $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new TypeOptimizerExtension()); + + $stream = $env->parse($env->tokenize(new Source('{% set thing = 42 %}{{ { foo: \'bar\', baz: thing } }}', 'index'))); + + $node = $stream->getNode('body')->getNode(0)->getNode(1)->getNode('expr'); + $type = $node->getAttribute('typeHint'); + + $this->assertInstanceOf(ArrayType::class, $type); + + $fooType = $type->getAttributeType('foo'); + $bazType = $type->getAttributeType('baz'); + + $this->assertInstanceOf(Type::class, $fooType); + $this->assertInstanceOf(Type::class, $bazType); + $this->assertSame('string', $fooType->getType()); + $this->assertSame('integer', $bazType->getType()); + } +} diff --git a/tests/ParserTest.php b/tests/ParserTest.php index cdd8e875743..f0d0d01fbc7 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -175,6 +175,39 @@ public function testGetVarName() $this->addToAssertionCount(1); } + public function testTypeHintWithoutType() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unexpected token "end of statement block" of value "" at line 1.'); + + $stream = new TokenStream([ + new Token(Token::BLOCK_START_TYPE, '', 1), + new Token(Token::NAME_TYPE, 'type', 1), + new Token(Token::NAME_TYPE, 'interval', 1), + new Token(Token::BLOCK_END_TYPE, '', 1), + new Token(Token::EOF_TYPE, '', 1), + ]); + $parser = new Parser(new Environment($this->createMock(LoaderInterface::class))); + $parser->parse($stream); + } + + public function testTypeHintWithExpressionAsType() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('A type hint must refer to a type with a constant name at line 1'); + + $stream = new TokenStream([ + new Token(Token::BLOCK_START_TYPE, '', 1), + new Token(Token::NAME_TYPE, 'type', 1), + new Token(Token::NAME_TYPE, 'interval', 1), + new Token(Token::NUMBER_TYPE, 17, 1), + new Token(Token::BLOCK_END_TYPE, '', 1), + new Token(Token::EOF_TYPE, '', 1), + ]); + $parser = new Parser(new Environment($this->createMock(LoaderInterface::class))); + $parser->parse($stream); + } + protected function getParser() { $parser = new Parser(new Environment($this->createMock(LoaderInterface::class)));