diff --git a/src/Ast/Type/ConditionalTypeForPropertyNode.php b/src/Ast/Type/ConditionalTypeForPropertyNode.php new file mode 100644 index 0000000..ae4575e --- /dev/null +++ b/src/Ast/Type/ConditionalTypeForPropertyNode.php @@ -0,0 +1,58 @@ +propertyExpression = $propertyExpression; + $this->targetType = $targetType; + $this->if = $if; + $this->else = $else; + $this->negated = $negated; + } + + public function __toString(): string + { + return sprintf( + '(%s %s %s ? %s : %s)', + $this->propertyExpression, + $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['propertyExpression'], $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/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..9c2e905 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -8,6 +8,7 @@ use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\ParserConfig; use function in_array; +use function str_contains; use function str_replace; use function strlen; use function strpos; @@ -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,101 @@ private function parseConditionalForParameter(TokenIterator $tokens, string $par return new Ast\Type\ConditionalTypeForParameterNode($parameterName, $targetType, $ifType, $elseType, $negated); } + /** @phpstan-impure */ + private function parsePropertyExpression(TokenIterator $tokens): ?string + { + $tokens->pushSavePoint(); + + // Handle $this->prop or $variable->prop + if ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE) || $tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) { + $expression = $tokens->currentTokenValue(); + if ($tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) { + $tokens->consumeTokenType(Lexer::TOKEN_THIS_VARIABLE); + } else { + $tokens->consumeTokenType(Lexer::TOKEN_VARIABLE); + } + + // Parse chained property access: $this->a->b->c + while ($tokens->isCurrentTokenType(Lexer::TOKEN_ARROW)) { + $expression .= '->'; + $tokens->consumeTokenType(Lexer::TOKEN_ARROW); + + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_IDENTIFIER)) { + $tokens->rollback(); + return null; + } + + $propertyName = $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + $expression .= $propertyName; + } + + if (!str_contains($expression, '->')) { + $tokens->rollback(); + return null; + } + + $tokens->dropSavePoint(); + return $expression; + + } elseif ($tokens->isCurrentTokenValue('self') || + $tokens->isCurrentTokenValue('parent') || + $tokens->isCurrentTokenValue('static')) { + $expression = $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) { + $tokens->rollback(); + return null; + } + + $expression .= '::'; + $tokens->consumeTokenType(Lexer::TOKEN_DOUBLE_COLON); + + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) { + $tokens->rollback(); + return null; + } + + $expression .= $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_VARIABLE); + + $tokens->dropSavePoint(); + return $expression; + } + + $tokens->dropSavePoint(); + return null; + } + + /** @phpstan-impure */ + private function parseConditionalForProperty(TokenIterator $tokens, string $propertyExpression): 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($propertyExpression, $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..2a62932 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)', + $node->propertyExpression, + $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..ec31c8e 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; @@ -3051,6 +3052,162 @@ 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( + '$this->data', + new IdentifierTypeNode('array'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false, + ), + ], + [ + '($this->data is array ? array : null)', + new ConditionalTypeForPropertyNode( + '$this->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( + '$this->config->database', + new IdentifierTypeNode('null'), + new IdentifierTypeNode('void'), + new IdentifierTypeNode('never'), + false, + ), + ], + [ + '(self::$config is null ? void : never)', + new ConditionalTypeForPropertyNode( + 'self::$config', + new IdentifierTypeNode('null'), + new IdentifierTypeNode('void'), + new IdentifierTypeNode('never'), + false, + ), + ], + [ + '(parent::$value is int ? int : string)', + new ConditionalTypeForPropertyNode( + 'parent::$value', + new IdentifierTypeNode('int'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false, + ), + ], + [ + '(static::$instance is null ? void : never)', + new ConditionalTypeForPropertyNode( + 'static::$instance', + new IdentifierTypeNode('null'), + new IdentifierTypeNode('void'), + new IdentifierTypeNode('never'), + false, + ), + ], + [ + '($this->prop is not array ? int : string)', + new ConditionalTypeForPropertyNode( + '$this->prop', + new IdentifierTypeNode('array'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + true, + ), + ], + [ + '(($this->data is array ? int : string)|null)', + new UnionTypeNode([ + new ConditionalTypeForPropertyNode( + '$this->data', + new IdentifierTypeNode('array'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false, + ), + new IdentifierTypeNode('null'), + ]), + ], + [ + '($this->a->b->c is Type ? Foo : Bar)', + new ConditionalTypeForPropertyNode( + '$this->a->b->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, + ), + ], ]; }