From 2bc0abf22d4517e7e2988f6398a22d71ab218dd3 Mon Sep 17 00:00:00 2001 From: Joshua Behrens Date: Wed, 29 Nov 2023 00:15:31 +0100 Subject: [PATCH 1/8] Add type tag for accessor prediction --- doc/tags/type.rst | 9 +++ src/Extension/CoreExtension.php | 2 + src/Node/TypeHintNode.php | 32 ++++++++++ src/TokenParser/TypeHintTokenParser.php | 69 +++++++++++++++++++++ tests/Fixtures/tags/type/tag.type-hint.test | 11 ++++ tests/LexerTest.php | 13 ++++ tests/Node/TypeHintTest.php | 36 +++++++++++ tests/ParserTest.php | 33 ++++++++++ 8 files changed, 205 insertions(+) create mode 100644 doc/tags/type.rst create mode 100644 src/Node/TypeHintNode.php create mode 100644 src/TokenParser/TypeHintTokenParser.php create mode 100644 tests/Fixtures/tags/type/tag.type-hint.test create mode 100644 tests/Node/TypeHintTest.php 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/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/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/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/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..83f8614417f 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php @@ -131,6 +131,19 @@ public function testLongComments() $this->addToAssertionCount(1); } + 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/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/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))); From 48e4e38610afd90fb339f1a6131982fa28d1c8dd Mon Sep 17 00:00:00 2001 From: Joshua Behrens Date: Sun, 26 Nov 2023 19:40:51 +0100 Subject: [PATCH 2/8] Parse comments to type hint tags --- src/Lexer.php | 22 ++++++++++++++++++- .../Fixtures/tags/type/comment.type-hint.test | 11 ++++++++++ tests/LexerTest.php | 13 +++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/tags/type/comment.type-hint.test 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/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/LexerTest.php b/tests/LexerTest.php index 83f8614417f..2d390c5ef6d 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php @@ -131,6 +131,19 @@ 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" %}'; From 3c98bd80fa822c4f724b9b5fdc3e56034cf58a30 Mon Sep 17 00:00:00 2001 From: Joshua Behrens Date: Mon, 4 Dec 2023 02:27:56 +0100 Subject: [PATCH 3/8] Add type hint prediction using extension --- src/Extension/TypeOptimizerExtension.php | 32 ++ src/NodeVisitor/TypeEvaluateNodeVisitor.php | 352 ++++++++++++++++++ src/TypeHint/ArrayType.php | 49 +++ src/TypeHint/ObjectType.php | 116 ++++++ src/TypeHint/Type.php | 39 ++ src/TypeHint/TypeFactory.php | 62 +++ src/TypeHint/TypeInterface.php | 24 ++ src/TypeHint/UnionType.php | 78 ++++ .../TypeEvaluateNodeVisitorTest.php | 103 +++++ 9 files changed, 855 insertions(+) create mode 100644 src/Extension/TypeOptimizerExtension.php create mode 100644 src/NodeVisitor/TypeEvaluateNodeVisitor.php create mode 100644 src/TypeHint/ArrayType.php create mode 100644 src/TypeHint/ObjectType.php create mode 100644 src/TypeHint/Type.php create mode 100644 src/TypeHint/TypeFactory.php create mode 100644 src/TypeHint/TypeInterface.php create mode 100644 src/TypeHint/UnionType.php create mode 100644 tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php 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/NodeVisitor/TypeEvaluateNodeVisitor.php b/src/NodeVisitor/TypeEvaluateNodeVisitor.php new file mode 100644 index 00000000000..8281989374a --- /dev/null +++ b/src/NodeVisitor/TypeEvaluateNodeVisitor.php @@ -0,0 +1,352 @@ + + * + * @internal + */ +final class TypeEvaluateNodeVisitor implements NodeVisitorInterface +{ + public function enterNode(Node $node, Environment $env): Node + { + return $node; + } + + public function leaveNode(Node $node, Environment $env): ?Node + { + $possibleTypes = []; + + foreach ($this->getPossibleTypes($node) as $possibleType) { + if (!$possibleType instanceof TypeInterface) { + $possibleType = TypeFactory::createTypeFromText((string) $possibleType); + } + + $possibleTypes[] = $possibleType; + } + + if (\count($possibleTypes) !== 1) { + $node->setAttribute('typeHint', new UnionType($possibleTypes)); + } elseif ($possibleTypes !== []) { + $node->setAttribute('typeHint', $possibleTypes[0]); + } + + 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)); + } + } + + 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)); + } + } + + return $node; + } + + public function getPriority(): int + { + return 10; + } + + private function getPossibleTypes(Node $node): iterable + { + if ($node instanceof AutoEscapeNode) { + yield 'string'; + } + + if ($node instanceof ConstantExpression) { + yield from $this->getPossibleConstantExpressionTypes($node); + } + + 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 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; + } + + yield $innerNode->getAttribute('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/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/ObjectType.php b/src/TypeHint/ObjectType.php new file mode 100644 index 00000000000..f4f98cd2915 --- /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); + } + + private 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; + } + } + + private 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..ca43cc4bba6 --- /dev/null +++ b/src/TypeHint/TypeFactory.php @@ -0,0 +1,62 @@ + + */ +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 (\str_starts_with($propertyType, '\\')) { + try { + $types[] = self::createObjectType($propertyType); + } catch (\Throwable) { + continue; + } + } else { + $types[] = self::createPlainType($propertyType); + } + } + + 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(\ltrim($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..1cb323b963a --- /dev/null +++ b/src/TypeHint/UnionType.php @@ -0,0 +1,78 @@ + + */ +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; + } + } + + if ($result === []) { + return null; + } + + if (\count($result) === 1) { + return $result[0]; + } + + return new UnionType($result); + } +} diff --git a/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php b/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php new file mode 100644 index 00000000000..9d02aac8c52 --- /dev/null +++ b/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php @@ -0,0 +1,103 @@ +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()); + } +} From b95c44fc17842a9ea05695366cfd28c91bb659f5 Mon Sep 17 00:00:00 2001 From: Joshua Behrens Date: Mon, 4 Dec 2023 02:35:13 +0100 Subject: [PATCH 4/8] Change order of with node children nodes to ensure variables are interpreted before body so type hints can be referenced correctly --- src/Node/WithNode.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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); } From 2443ca88983af3e37a3d1bdbf47f78dbbae2d948 Mon Sep 17 00:00:00 2001 From: Joshua Behrens Date: Mon, 4 Dec 2023 02:52:38 +0100 Subject: [PATCH 5/8] Improve code generation of predictable array access --- src/Node/Expression/GetAttrExpression.php | 22 +++++++++++++++++++++ src/NodeVisitor/TypeEvaluateNodeVisitor.php | 20 +++++++++++++++++++ tests/Node/Expression/GetAttrTest.php | 20 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index 29a446b881b..1397dddebb6 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -15,6 +15,8 @@ use Twig\Compiler; use Twig\Extension\SandboxExtension; use Twig\Template; +use Twig\TypeHint\ArrayType; +use Twig\TypeHint\TypeFactory; class GetAttrExpression extends AbstractExpression { @@ -32,6 +34,26 @@ public function compile(Compiler $compiler): void { $env = $compiler->getEnvironment(); + if ($this->getNode('attribute') instanceof ConstantExpression) { + $type = TypeFactory::createTypeFromText('null'); + + if ($this->getNode('node')->hasAttribute('typeHint')) { + $type = $this->getNode('node')->getAttribute('typeHint'); + } + + if ($type instanceof ArrayType) { + $compiler + ->raw('((') + ->subcompile($this->getNode('node')) + ->raw(')[') + ->subcompile($this->getNode('attribute')) + ->raw('] ?? null)') + ; + + return; + } + } + // optimize array calls if ( $this->getAttribute('optimizable') diff --git a/src/NodeVisitor/TypeEvaluateNodeVisitor.php b/src/NodeVisitor/TypeEvaluateNodeVisitor.php index 8281989374a..0984851a703 100644 --- a/src/NodeVisitor/TypeEvaluateNodeVisitor.php +++ b/src/NodeVisitor/TypeEvaluateNodeVisitor.php @@ -45,6 +45,7 @@ use Twig\Node\Expression\Binary\SubBinary; use Twig\Node\Expression\BlockReferenceExpression; use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\GetAttrExpression; use Twig\Node\Expression\ParentExpression; use Twig\Node\Expression\TestExpression; use Twig\Node\Expression\Unary\NegUnary; @@ -155,6 +156,25 @@ private function getPossibleTypes(Node $node): iterable 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'); + } + + if ($typeHint instanceof TypeInterface) { + $variableType = $typeHint->getAttributeType($attributeName); + + if ($variableType !== null) { + yield $variableType; + } + } + } + } + if ($node instanceof MacroNode) { yield 'string'; } diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index c76fb3992d5..e18eb28ca52 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,23 @@ 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, + ]; + return $tests; } } From b6d0ddb1fb60b13b08911a96fed94f8ed85e4853 Mon Sep 17 00:00:00 2001 From: Joshua Behrens Date: Tue, 5 Dec 2023 00:00:34 +0100 Subject: [PATCH 6/8] Track variable types on the stack and support looking up the types --- src/Environment.php | 9 +++ src/NodeVisitor/TypeEvaluateNodeVisitor.php | 51 +++++++++++++- src/TypeHint/ContextStack.php | 68 +++++++++++++++++++ src/TypeHint/TypeFactory.php | 5 ++ src/TypeHint/UnionType.php | 10 +-- tests/Node/Expression/GetAttrTest.php | 16 +++++ .../TypeEvaluateNodeVisitorTest.php | 22 ++++++ 7 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 src/TypeHint/ContextStack.php 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/NodeVisitor/TypeEvaluateNodeVisitor.php b/src/NodeVisitor/TypeEvaluateNodeVisitor.php index 0984851a703..f851d76ad1f 100644 --- a/src/NodeVisitor/TypeEvaluateNodeVisitor.php +++ b/src/NodeVisitor/TypeEvaluateNodeVisitor.php @@ -46,6 +46,7 @@ use Twig\Node\Expression\BlockReferenceExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\GetAttrExpression; +use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\ParentExpression; use Twig\Node\Expression\TestExpression; use Twig\Node\Expression\Unary\NegUnary; @@ -54,6 +55,7 @@ use Twig\Node\MacroNode; use Twig\Node\Node; use Twig\Node\SetNode; +use Twig\Node\WithNode; use Twig\TypeHint\ArrayType; use Twig\TypeHint\TypeFactory; use Twig\TypeHint\TypeInterface; @@ -72,6 +74,13 @@ 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')); + } + } + return $node; } @@ -79,7 +88,7 @@ public function leaveNode(Node $node, Environment $env): ?Node { $possibleTypes = []; - foreach ($this->getPossibleTypes($node) as $possibleType) { + foreach ($this->getPossibleTypes($node, $env) as $possibleType) { if (!$possibleType instanceof TypeInterface) { $possibleType = TypeFactory::createTypeFromText((string) $possibleType); } @@ -114,6 +123,10 @@ public function leaveNode(Node $node, Environment $env): ?Node if ($typedVariables !== []) { $node->setAttribute('typeHint', new ArrayType($typedVariables)); + + foreach ($typedVariables as $typedVariableName => $typedVariableTypes) { + $env->getTypeHintStack()->addVariableType($typedVariableName, $typedVariableTypes); + } } } @@ -138,6 +151,33 @@ public function leaveNode(Node $node, Environment $env): ?Node } } + // 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(); + } + } + return $node; } @@ -146,7 +186,7 @@ public function getPriority(): int return 10; } - private function getPossibleTypes(Node $node): iterable + private function getPossibleTypes(Node $node, Environment $env): iterable { if ($node instanceof AutoEscapeNode) { yield 'string'; @@ -163,6 +203,9 @@ private function getPossibleTypes(Node $node): iterable 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) { @@ -192,6 +235,10 @@ private function getPossibleTypes(Node $node): iterable yield 'string'; } + if ($node instanceof NameExpression && !$node instanceof AssignNameExpression) { + yield $env->getTypeHintStack()->getVariableType($node->getAttribute('name')); + } + if ($node instanceof TestExpression) { yield 'boolean'; } 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/TypeFactory.php b/src/TypeHint/TypeFactory.php index ca43cc4bba6..f4770f6255f 100644 --- a/src/TypeHint/TypeFactory.php +++ b/src/TypeHint/TypeFactory.php @@ -40,6 +40,11 @@ public static function createTypeFromText(string $type): ?TypeInterface } } + return static::createTypeFromCollection($types); + } + + public static function createTypeFromCollection(array $types): ?TypeInterface + { if ($types === []) { return null; } diff --git a/src/TypeHint/UnionType.php b/src/TypeHint/UnionType.php index 1cb323b963a..f6c9d33bbf3 100644 --- a/src/TypeHint/UnionType.php +++ b/src/TypeHint/UnionType.php @@ -65,14 +65,6 @@ public function getAttributeType(string|int $attribute): ?TypeInterface } } - if ($result === []) { - return null; - } - - if (\count($result) === 1) { - return $result[0]; - } - - return new UnionType($result); + return TypeFactory::createTypeFromCollection($result); } } diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index e18eb28ca52..1a70cec9c7f 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -75,6 +75,22 @@ public function getTests() $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, + true, + ]; + return $tests; } } diff --git a/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php b/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php index 9d02aac8c52..8d465e8a2bd 100644 --- a/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php +++ b/tests/NodeVisitor/TypeEvaluateNodeVisitorTest.php @@ -20,6 +20,7 @@ use Twig\Node\Expression\BlockReferenceExpression; use Twig\Node\PrintNode; use Twig\Node\SetNode; +use Twig\Node\WithNode; use Twig\Source; use Twig\TypeHint\ArrayType; use Twig\TypeHint\Type; @@ -100,4 +101,25 @@ public function testSetVariableIsAssignedArrayObject(): void $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()); + } } From b65237d46a66efa2c27708da58fa41bb6cd41491 Mon Sep 17 00:00:00 2001 From: Joshua Behrens Date: Mon, 4 Dec 2023 23:59:37 +0100 Subject: [PATCH 7/8] Compile method calls --- src/Node/Expression/GetAttrExpression.php | 51 ++++++++++- src/NodeVisitor/TypeEvaluateNodeVisitor.php | 38 ++++++-- src/TypeHint/ObjectType.php | 4 +- src/TypeHint/TypeFactory.php | 16 ++-- tests/Node/Expression/GetAttrTest.php | 98 ++++++++++++++++++++- 5 files changed, 187 insertions(+), 20 deletions(-) diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index 1397dddebb6..e89910b72f5 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -16,7 +16,7 @@ use Twig\Extension\SandboxExtension; use Twig\Template; use Twig\TypeHint\ArrayType; -use Twig\TypeHint\TypeFactory; +use Twig\TypeHint\ObjectType; class GetAttrExpression extends AbstractExpression { @@ -35,7 +35,7 @@ public function compile(Compiler $compiler): void $env = $compiler->getEnvironment(); if ($this->getNode('attribute') instanceof ConstantExpression) { - $type = TypeFactory::createTypeFromText('null'); + $type = null; if ($this->getNode('node')->hasAttribute('typeHint')) { $type = $this->getNode('node')->getAttribute('typeHint'); @@ -51,6 +51,53 @@ public function compile(Compiler $compiler): void ; return; + } else if ($type instanceof ObjectType && $this->getNode('attribute') instanceof ConstantExpression) { + $attributeName = $this->getNode('attribute')->getAttribute('value'); + + if ($type->getPropertyType($attributeName) !== null) { + $compiler + ->raw('((') + ->subcompile($this->getNode('node')) + ->raw(')?->') + ->raw($attributeName) + ->raw(')') + ; + + return; + } + + /** 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) { + $compiler + ->raw('((') + ->subcompile($this->getNode('node')) + ->raw(')?->') + ->raw($methodName) + ->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; + } + } } } diff --git a/src/NodeVisitor/TypeEvaluateNodeVisitor.php b/src/NodeVisitor/TypeEvaluateNodeVisitor.php index f851d76ad1f..c5dbdd90d63 100644 --- a/src/NodeVisitor/TypeEvaluateNodeVisitor.php +++ b/src/NodeVisitor/TypeEvaluateNodeVisitor.php @@ -13,6 +13,7 @@ use Twig\Environment; use Twig\Node\AutoEscapeNode; +use Twig\Node\BodyNode; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\AssignNameExpression; use Twig\Node\Expression\Binary\AddBinary; @@ -55,6 +56,7 @@ use Twig\Node\MacroNode; use Twig\Node\Node; use Twig\Node\SetNode; +use Twig\Node\TypeHintNode; use Twig\Node\WithNode; use Twig\TypeHint\ArrayType; use Twig\TypeHint\TypeFactory; @@ -81,6 +83,14 @@ public function enterNode(Node $node, Environment $env): Node } } + if ($node instanceof TypeHintNode) { + $env->getTypeHintStack()->addVariableType($node->getAttribute('name'), TypeFactory::createTypeFromText($node->getAttribute('type'))); + } + + if ($node instanceof BodyNode) { + $env->getTypeHintStack()->pushMinorStack(); + } + return $node; } @@ -89,17 +99,19 @@ public function leaveNode(Node $node, Environment $env): ?Node $possibleTypes = []; foreach ($this->getPossibleTypes($node, $env) as $possibleType) { - if (!$possibleType instanceof TypeInterface) { + if (!$possibleType instanceof TypeInterface && $possibleType !== null) { $possibleType = TypeFactory::createTypeFromText((string) $possibleType); } $possibleTypes[] = $possibleType; } - if (\count($possibleTypes) !== 1) { - $node->setAttribute('typeHint', new UnionType($possibleTypes)); - } elseif ($possibleTypes !== []) { - $node->setAttribute('typeHint', $possibleTypes[0]); + if ($possibleTypes !== []) { + if (\count($possibleTypes) === 1) { + $node->setAttribute('typeHint', $possibleTypes[0]); + } else { + $node->setAttribute('typeHint', new UnionType($possibleTypes)); + } } if ($node instanceof SetNode) { @@ -178,6 +190,10 @@ public function leaveNode(Node $node, Environment $env): ?Node } } + if ($node instanceof BodyNode) { + $env->getTypeHintStack()->popMinorStack(); + } + return $node; } @@ -236,7 +252,11 @@ private function getPossibleTypes(Node $node, Environment $env): iterable } if ($node instanceof NameExpression && !$node instanceof AssignNameExpression) { - yield $env->getTypeHintStack()->getVariableType($node->getAttribute('name')); + $result = $env->getTypeHintStack()->getVariableType($node->getAttribute('name')); + + if ($result !== null) { + yield $result; + } } if ($node instanceof TestExpression) { @@ -266,7 +286,11 @@ private function getPossibleTypes(Node $node, Environment $env): iterable continue; } - yield $innerNode->getAttribute('typeHint'); + $typeHint = $innerNode->getAttribute('typeHint'); + + if ($typeHint !== null) { + yield $typeHint; + } } } } diff --git a/src/TypeHint/ObjectType.php b/src/TypeHint/ObjectType.php index f4f98cd2915..b0e555381e1 100644 --- a/src/TypeHint/ObjectType.php +++ b/src/TypeHint/ObjectType.php @@ -47,7 +47,7 @@ public function getAttributeType(string|int $attribute): ?TypeInterface ?? $this->getMethodType('has' . $attribute); } - private function getPropertyType(string $name): ?TypeInterface + public function getPropertyType(string $name): ?TypeInterface { if (\array_key_exists($name, $this->properties)) { return $this->properties[$name]; @@ -75,7 +75,7 @@ private function createPropertyType(string $name): ?TypeInterface } } - private function getMethodType(string $name): ?TypeInterface + public function getMethodType(string $name): ?TypeInterface { if (\array_key_exists($name, $this->methods)) { return $this->methods[$name]; diff --git a/src/TypeHint/TypeFactory.php b/src/TypeHint/TypeFactory.php index f4770f6255f..1fdaf97dc20 100644 --- a/src/TypeHint/TypeFactory.php +++ b/src/TypeHint/TypeFactory.php @@ -29,13 +29,13 @@ public static function createTypeFromText(string $type): ?TypeInterface $types = []; foreach (\explode('|', $type) as $propertyType) { - if (\str_starts_with($propertyType, '\\')) { - try { - $types[] = self::createObjectType($propertyType); - } catch (\Throwable) { - continue; - } - } else { + if ($propertyType === '') { + continue; + } + + try { + $types[] = self::createObjectType(\ltrim($propertyType, '\\')); + } catch (\Throwable) { $types[] = self::createPlainType($propertyType); } } @@ -62,6 +62,6 @@ private static function createPlainType(string $type): Type */ private static function createObjectType(string $class): ObjectType { - return self::$objectTypeCache[$class] ??= new ObjectType(new \ReflectionClass(\ltrim($class, '\\'))); + return self::$objectTypeCache[$class] ??= new ObjectType(new \ReflectionClass($class)); } } diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index 1a70cec9c7f..580da7459e1 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -88,9 +88,105 @@ public function getTests() echo ((((($context["foo"] ?? null))["bar"] ?? null))["baz"] ?? null); PHP, $optimizedEnv, - true, + ]; + + $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, ]; 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); + } +} From c6c29b5e78c7240c61a21a10d62b07e7bd0d00d6 Mon Sep 17 00:00:00 2001 From: Joshua Behrens Date: Tue, 5 Dec 2023 03:04:27 +0100 Subject: [PATCH 8/8] Allow union type predicted type access --- src/Node/Expression/GetAttrExpression.php | 344 +++++++++++++++++----- tests/Node/Expression/GetAttrTest.php | 28 +- 2 files changed, 292 insertions(+), 80 deletions(-) diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index e89910b72f5..85adc55470c 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -13,10 +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 { @@ -41,63 +44,23 @@ public function compile(Compiler $compiler): void $type = $this->getNode('node')->getAttribute('typeHint'); } - if ($type instanceof ArrayType) { - $compiler - ->raw('((') - ->subcompile($this->getNode('node')) - ->raw(')[') - ->subcompile($this->getNode('attribute')) - ->raw('] ?? null)') - ; + if ($type instanceof TypeInterface) { + $sourceCompiler = $this->createNodeSourceCompiler(); + $accessCompiler = $this->createAccessCompiler($type, $env); - return; - } else if ($type instanceof ObjectType && $this->getNode('attribute') instanceof ConstantExpression) { - $attributeName = $this->getNode('attribute')->getAttribute('value'); - - if ($type->getPropertyType($attributeName) !== null) { - $compiler - ->raw('((') - ->subcompile($this->getNode('node')) - ->raw(')?->') - ->raw($attributeName) - ->raw(')') - ; - - return; + 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(')'); } - /** 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) { - $compiler - ->raw('((') - ->subcompile($this->getNode('node')) - ->raw(')?->') - ->raw($methodName) - ->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; - } - } + return; } } @@ -126,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()); + } + + /** + * @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); + } + + 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; + } - if ($this->getAttribute('ignore_strict_check')) { - $this->getNode('node')->setAttribute('ignore_strict_check', true); + return $this->createObjectMethodAccessCompiler($type, $methodName); + } } - $compiler - ->subcompile($this->getNode('node')) - ->raw(', ') - ->subcompile($this->getNode('attribute')) - ; + 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); - if ($this->hasNode('arguments')) { - $compiler->raw(', ')->subcompile($this->getNode('arguments')); - } else { - $compiler->raw(', []'); + $compiler->raw('?->')->raw($attributeName)->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/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index 580da7459e1..19942ed2f62 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -70,7 +70,7 @@ public function getTests() )->getNode('body'), <<<'PHP' // line 1 -echo ((((["bar" => ["baz" => 42]])["bar"] ?? null))["baz"] ?? null); +echo ((["bar" => ["baz" => 42]]["bar"] ?? null)["baz"] ?? null); PHP, $optimizedEnv, ]; @@ -85,7 +85,7 @@ public function getTests() // line 1 $context["foo"] = ["bar" => ["baz" => 42]]; // line 2 -echo ((((($context["foo"] ?? null))["bar"] ?? null))["baz"] ?? null); +echo ((($context["foo"] ?? null)["bar"] ?? null)["baz"] ?? null); PHP, $optimizedEnv, ]; @@ -98,7 +98,7 @@ public function getTests() <<<'PHP' // line 1 // line 2 -echo ((($context["obj"] ?? null))?->name); +echo ($context["obj"] ?? null)?->name; PHP, $optimizedEnv, ]; @@ -111,7 +111,7 @@ public function getTests() <<<'PHP' // line 1 // line 2 -echo ((($context["obj"] ?? null))?->getname()); +echo (($context["obj"] ?? null)?->getname()); PHP, $optimizedEnv, ]; @@ -124,7 +124,7 @@ public function getTests() <<<'PHP' // line 1 // line 2 -echo ((($context["obj"] ?? null))?->byName("foobar")); +echo (($context["obj"] ?? null)?->byName("foobar")); PHP, $optimizedEnv, ]; @@ -137,7 +137,23 @@ public function getTests() <<<'PHP' // line 1 // line 2 -echo ((((($context["obj"] ?? null))?->getinstance()))?->getname()); +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, ];