diff --git a/src/Ast/Type/ConditionalTypeForPropertyNode.php b/src/Ast/Type/ConditionalTypeForPropertyNode.php new file mode 100644 index 0000000..9778534 --- /dev/null +++ b/src/Ast/Type/ConditionalTypeForPropertyNode.php @@ -0,0 +1,70 @@ +subject = $subject; + $this->targetType = $targetType; + $this->if = $if; + $this->else = $else; + $this->negated = $negated; + } + + public function __toString(): string + { + return sprintf( + '(%s %s %s ? %s : %s)', + $this->subject, + $this->negated ? 'is not' : 'is', + $this->targetType, + $this->if, + $this->else, + ); + } + + /** + * @param array $properties + */ + public static function __set_state(array $properties): self + { + $instance = new self( + $properties['subject'], + $properties['targetType'], + $properties['if'], + $properties['else'], + $properties['negated'], + ); + if (isset($properties['attributes'])) { + foreach ($properties['attributes'] as $key => $value) { + $instance->setAttribute($key, $value); + } + } + return $instance; + } + +} diff --git a/src/Ast/Type/PropertyAccessNode.php b/src/Ast/Type/PropertyAccessNode.php new file mode 100644 index 0000000..d8c3284 --- /dev/null +++ b/src/Ast/Type/PropertyAccessNode.php @@ -0,0 +1,78 @@ + */ + public array $path; + + /** + * @param self::HOLDER_*|null $holder + * @param list $path + */ + public function __construct(bool $isStatic, ?string $holder, array $path) + { + $this->isStatic = $isStatic; + $this->holder = $holder; + $this->path = $path; + } + + public function __toString(): string + { + if ($this->isStatic) { + return $this->holder . '::$' . $this->path[0]->name; + } + + $pathString = implode('->', array_map( + static fn (PropertyAccessPathItem $item): string => $item->name, + $this->path, + )); + + return '$this->' . $pathString; + } + + /** + * @param array $properties + */ + public static function __set_state(array $properties): self + { + $instance = new self( + $properties['isStatic'], + $properties['holder'], + $properties['path'], + ); + if (isset($properties['attributes'])) { + foreach ($properties['attributes'] as $key => $value) { + $instance->setAttribute($key, $value); + } + } + return $instance; + } + +} diff --git a/src/Ast/Type/PropertyAccessPathItem.php b/src/Ast/Type/PropertyAccessPathItem.php new file mode 100644 index 0000000..0651f1a --- /dev/null +++ b/src/Ast/Type/PropertyAccessPathItem.php @@ -0,0 +1,46 @@ +config->database`, there are two items: 'config' and 'database' + * - In `self::$cache`, there is one item: 'cache' + */ +class PropertyAccessPathItem implements Node +{ + + use NodeAttributes; + + public string $name; + + public function __construct(string $name) + { + $this->name = $name; + } + + public function __toString(): string + { + return $this->name; + } + + /** + * @param array $properties + */ + public static function __set_state(array $properties): self + { + $instance = new self($properties['name']); + if (isset($properties['attributes'])) { + foreach ($properties['attributes'] as $key => $value) { + $instance->setAttribute($key, $value); + } + } + return $instance; + } + +} diff --git a/src/Lexer/Lexer.php b/src/Lexer/Lexer.php index e2e0e57..cb216ba 100644 --- a/src/Lexer/Lexer.php +++ b/src/Lexer/Lexer.php @@ -140,7 +140,7 @@ private function generateRegexp(): string $patterns = [ self::TOKEN_HORIZONTAL_WS => '[\\x09\\x20]++', - self::TOKEN_IDENTIFIER => '(?:[\\\\]?+[a-z_\\x80-\\xFF][0-9a-z_\\x80-\\xFF-]*+)++', + self::TOKEN_IDENTIFIER => '(?:[\\\\]?+[a-z_\\x80-\\xFF](?:[0-9a-z_\\x80-\\xFF]|(?!->)-)*+)++', self::TOKEN_THIS_VARIABLE => '\\$this(?![0-9a-z_\\x80-\\xFF])', self::TOKEN_VARIABLE => '\\$[a-z_\\x80-\\xFF][0-9a-z_\\x80-\\xFF]*+', diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 077dd6b..62d72e4 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -11,6 +11,7 @@ use function str_replace; use function strlen; use function strpos; +use function substr; use function substr_compare; class TypeParser @@ -113,29 +114,68 @@ private function subParse(TokenIterator $tokens): Ast\Type\TypeNode if ($tokens->isCurrentTokenType(Lexer::TOKEN_NULLABLE)) { $type = $this->parseNullable($tokens); - } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) { - $type = $this->parseConditionalForParameter($tokens, $tokens->currentTokenValue()); + } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) { + $propertyExpression = $this->parsePropertyExpression($tokens); - } else { - $type = $this->parseAtomic($tokens); + if ($propertyExpression !== null && $tokens->isCurrentTokenValue('is')) { + $type = $this->parseConditionalForProperty($tokens, $propertyExpression); + } else { + $type = $this->parseAtomic($tokens); + $type = $this->parseUnionOrIntersectionIfPresent($tokens, $type); + } - if ($tokens->isCurrentTokenValue('is')) { - $type = $this->parseConditional($tokens, $type); + } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) { + $parameterName = $tokens->currentTokenValue(); + $propertyExpression = $this->parsePropertyExpression($tokens); + + if ($propertyExpression !== null && $tokens->isCurrentTokenValue('is')) { + $type = $this->parseConditionalForProperty($tokens, $propertyExpression); } else { - $tokens->skipNewLineTokensAndConsumeComments(); + $type = $this->parseConditionalForParameter($tokens, $parameterName); + } - if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { - $type = $this->subParseUnion($tokens, $type); + } else { + if ($tokens->isCurrentTokenValue('self') || + $tokens->isCurrentTokenValue('parent') || + $tokens->isCurrentTokenValue('static')) { + $propertyExpression = $this->parsePropertyExpression($tokens); - } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) { - $type = $this->subParseIntersection($tokens, $type); + if ($propertyExpression !== null && $tokens->isCurrentTokenValue('is')) { + $type = $this->parseConditionalForProperty($tokens, $propertyExpression); + } else { + $type = $this->parseAtomic($tokens); + $type = $this->parseUnionOrIntersectionIfPresent($tokens, $type); } + } else { + $type = $this->parseAtomic($tokens); + $type = $this->parseUnionOrIntersectionIfPresent($tokens, $type); } } return $this->enrichWithAttributes($tokens, $type, $startLine, $startIndex); } + /** @phpstan-impure */ + private function parseUnionOrIntersectionIfPresent( + TokenIterator $tokens, + Ast\Type\TypeNode $type + ): Ast\Type\TypeNode + { + if ($tokens->isCurrentTokenValue('is')) { + return $this->parseConditional($tokens, $type); + } + + $tokens->skipNewLineTokensAndConsumeComments(); + + if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { + return $this->subParseUnion($tokens, $type); + } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) { + return $this->subParseIntersection($tokens, $type); + } + + return $type; + } + /** @phpstan-impure */ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode { @@ -392,6 +432,135 @@ private function parseConditionalForParameter(TokenIterator $tokens, string $par return new Ast\Type\ConditionalTypeForParameterNode($parameterName, $targetType, $ifType, $elseType, $negated); } + /** @phpstan-impure */ + private function parsePropertyExpression(TokenIterator $tokens): ?Ast\Type\PropertyAccessNode + { + $tokens->pushSavePoint(); + + if ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE) || $tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + $varName = $tokens->currentTokenValue(); + + if ($varName !== '$this') { + $tokens->dropSavePoint(); + return null; + } + + if ($tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) { + $tokens->consumeTokenType(Lexer::TOKEN_THIS_VARIABLE); + } else { + $tokens->consumeTokenType(Lexer::TOKEN_VARIABLE); + } + + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_ARROW)) { + $tokens->rollback(); + return null; + } + + $path = []; + + while ($tokens->isCurrentTokenType(Lexer::TOKEN_ARROW)) { + $tokens->consumeTokenType(Lexer::TOKEN_ARROW); + + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_IDENTIFIER)) { + $tokens->rollback(); + return null; + } + + $itemStartLine = $tokens->currentTokenLine(); + $itemStartIndex = $tokens->currentTokenIndex(); + $propertyName = $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + + $item = new Ast\Type\PropertyAccessPathItem($propertyName); + $path[] = $this->enrichWithAttributes($tokens, $item, $itemStartLine, $itemStartIndex); + } + + $tokens->dropSavePoint(); + $node = new Ast\Type\PropertyAccessNode(false, null, $path); + return $this->enrichWithAttributes($tokens, $node, $startLine, $startIndex); + + } elseif ($tokens->isCurrentTokenValue('self') || + $tokens->isCurrentTokenValue('parent') || + $tokens->isCurrentTokenValue('static')) { + + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + $holder = $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) { + $tokens->rollback(); + return null; + } + + $tokens->consumeTokenType(Lexer::TOKEN_DOUBLE_COLON); + + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) { + $tokens->rollback(); + return null; + } + + $itemStartLine = $tokens->currentTokenLine(); + $itemStartIndex = $tokens->currentTokenIndex(); + $propertyName = substr($tokens->currentTokenValue(), 1); + $tokens->consumeTokenType(Lexer::TOKEN_VARIABLE); + + $tokens->dropSavePoint(); + $item = new Ast\Type\PropertyAccessPathItem($propertyName); + $enrichedItem = $this->enrichWithAttributes($tokens, $item, $itemStartLine, $itemStartIndex); + + /** @var 'self'|'parent'|'static' $holder */ + $node = new Ast\Type\PropertyAccessNode( + true, + $holder, + [$enrichedItem], + ); + return $this->enrichWithAttributes($tokens, $node, $startLine, $startIndex); + } + + $tokens->dropSavePoint(); + return null; + } + + /** @phpstan-impure */ + private function parseConditionalForProperty( + TokenIterator $tokens, + Ast\Type\PropertyAccessNode $subject + ): Ast\Type\TypeNode + { + $tokens->consumeTokenValue(Lexer::TOKEN_IDENTIFIER, 'is'); + + $negated = false; + if ($tokens->isCurrentTokenValue('not')) { + $negated = true; + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + } + + $targetType = $this->parse($tokens); + + $tokens->skipNewLineTokensAndConsumeComments(); + $tokens->consumeTokenType(Lexer::TOKEN_NULLABLE); + $tokens->skipNewLineTokensAndConsumeComments(); + + $ifType = $this->parse($tokens); + + $tokens->skipNewLineTokensAndConsumeComments(); + $tokens->consumeTokenType(Lexer::TOKEN_COLON); + $tokens->skipNewLineTokensAndConsumeComments(); + + $elseType = $this->subParse($tokens); + + return new Ast\Type\ConditionalTypeForPropertyNode( + $subject, + $targetType, + $ifType, + $elseType, + $negated, + ); + } + /** @phpstan-impure */ private function parseNullable(TokenIterator $tokens): Ast\Type\TypeNode { diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index 36f6ebe..2d98fd3 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -51,6 +51,7 @@ use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForPropertyNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; @@ -434,6 +435,16 @@ private function printType(TypeNode $node): string $this->printType($node->else), ); } + if ($node instanceof ConditionalTypeForPropertyNode) { + return sprintf( + '(%s %s %s ? %s : %s)', + (string) $node->subject, + $node->negated ? 'is not' : 'is', + $this->printType($node->targetType), + $this->printType($node->if), + $this->printType($node->else), + ); + } if ($node instanceof ConditionalTypeNode) { return sprintf( '(%s %s %s ? %s : %s)', diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php index b0123ef..aa2c717 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -22,6 +22,7 @@ use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForPropertyNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; @@ -31,6 +32,8 @@ use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode; +use PHPStan\PhpDocParser\Ast\Type\PropertyAccessNode; +use PHPStan\PhpDocParser\Ast\Type\PropertyAccessPathItem; use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; @@ -3051,6 +3054,205 @@ public function provideParseData(): array ), ]), ], + [ + '($this is array ? int : string)', + new ConditionalTypeNode( + new ThisTypeNode(), + new IdentifierTypeNode('array'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false, + ), + ], + [ + '($this->data is array ? int : string)', + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + false, + null, + [new PropertyAccessPathItem('data')], + ), + new IdentifierTypeNode('array'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false, + ), + ], + [ + '($this->data is array ? array : null)', + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + false, + null, + [new PropertyAccessPathItem('data')], + ), + new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new IdentifierTypeNode('string'), + new IdentifierTypeNode('mixed'), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + GenericTypeNode::VARIANCE_INVARIANT, + ], + ), + new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new IdentifierTypeNode('string'), + new IdentifierTypeNode('mixed'), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + GenericTypeNode::VARIANCE_INVARIANT, + ], + ), + new IdentifierTypeNode('null'), + false, + ), + ], + [ + '($this->config->database is null ? void : never)', + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + false, + null, + [ + new PropertyAccessPathItem('config'), + new PropertyAccessPathItem('database'), + ], + ), + new IdentifierTypeNode('null'), + new IdentifierTypeNode('void'), + new IdentifierTypeNode('never'), + false, + ), + ], + [ + '(self::$config is null ? void : never)', + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + true, + PropertyAccessNode::HOLDER_SELF, + [new PropertyAccessPathItem('config')], + ), + new IdentifierTypeNode('null'), + new IdentifierTypeNode('void'), + new IdentifierTypeNode('never'), + false, + ), + ], + [ + '(parent::$value is int ? int : string)', + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + true, + PropertyAccessNode::HOLDER_PARENT, + [new PropertyAccessPathItem('value')], + ), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false, + ), + ], + [ + '(static::$instance is null ? void : never)', + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + true, + PropertyAccessNode::HOLDER_STATIC, + [new PropertyAccessPathItem('instance')], + ), + new IdentifierTypeNode('null'), + new IdentifierTypeNode('void'), + new IdentifierTypeNode('never'), + false, + ), + ], + [ + '($this->prop is not array ? int : string)', + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + false, + null, + [new PropertyAccessPathItem('prop')], + ), + new IdentifierTypeNode('array'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + true, + ), + ], + [ + '(($this->data is array ? int : string)|null)', + new UnionTypeNode([ + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + false, + null, + [new PropertyAccessPathItem('data')], + ), + new IdentifierTypeNode('array'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false, + ), + new IdentifierTypeNode('null'), + ]), + ], + [ + '($this->a->b->c is Type ? Foo : Bar)', + new ConditionalTypeForPropertyNode( + new PropertyAccessNode( + false, + null, + [ + new PropertyAccessPathItem('a'), + new PropertyAccessPathItem('b'), + new PropertyAccessPathItem('c'), + ], + ), + new IdentifierTypeNode('Type'), + new IdentifierTypeNode('Foo'), + new IdentifierTypeNode('Bar'), + false, + ), + ], + [ + '($this->data array ? int : string)', + new ParserException( + '?', + Lexer::TOKEN_NULLABLE, + 19, + Lexer::TOKEN_CLOSE_PARENTHESES, + null, + null, + ), + ], + [ + '($this->config-> is null ? void : never)', + new ParserException( + '?', + Lexer::TOKEN_NULLABLE, + 25, + Lexer::TOKEN_CLOSE_PARENTHESES, + null, + null, + ), + ], + [ + '(self::$prop)', + new ParserException( + ')', + Lexer::TOKEN_CLOSE_PARENTHESES, + 12, + Lexer::TOKEN_IDENTIFIER, + null, + null, + ), + ], ]; }