Skip to content

Commit d229ea7

Browse files
committed
feat: add support for property access in conditional types
1 parent f1ddda6 commit d229ea7

File tree

5 files changed

+373
-12
lines changed

5 files changed

+373
-12
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\PhpDocParser\Ast\Type;
4+
5+
use PHPStan\PhpDocParser\Ast\NodeAttributes;
6+
use function sprintf;
7+
8+
class ConditionalTypeForPropertyNode implements TypeNode
9+
{
10+
11+
use NodeAttributes;
12+
13+
public string $propertyExpression;
14+
15+
public TypeNode $targetType;
16+
17+
public TypeNode $if;
18+
19+
public TypeNode $else;
20+
21+
public bool $negated;
22+
23+
public function __construct(string $propertyExpression, TypeNode $targetType, TypeNode $if, TypeNode $else, bool $negated)
24+
{
25+
$this->propertyExpression = $propertyExpression;
26+
$this->targetType = $targetType;
27+
$this->if = $if;
28+
$this->else = $else;
29+
$this->negated = $negated;
30+
}
31+
32+
public function __toString(): string
33+
{
34+
return sprintf(
35+
'(%s %s %s ? %s : %s)',
36+
$this->propertyExpression,
37+
$this->negated ? 'is not' : 'is',
38+
$this->targetType,
39+
$this->if,
40+
$this->else,
41+
);
42+
}
43+
44+
/**
45+
* @param array<string, mixed> $properties
46+
*/
47+
public static function __set_state(array $properties): self
48+
{
49+
$instance = new self($properties['propertyExpression'], $properties['targetType'], $properties['if'], $properties['else'], $properties['negated']);
50+
if (isset($properties['attributes'])) {
51+
foreach ($properties['attributes'] as $key => $value) {
52+
$instance->setAttribute($key, $value);
53+
}
54+
}
55+
return $instance;
56+
}
57+
58+
}

src/Lexer/Lexer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ private function generateRegexp(): string
140140
$patterns = [
141141
self::TOKEN_HORIZONTAL_WS => '[\\x09\\x20]++',
142142

143-
self::TOKEN_IDENTIFIER => '(?:[\\\\]?+[a-z_\\x80-\\xFF][0-9a-z_\\x80-\\xFF-]*+)++',
143+
self::TOKEN_IDENTIFIER => '(?:[\\\\]?+[a-z_\\x80-\\xFF](?:[0-9a-z_\\x80-\\xFF]|(?!->)-)*+)++',
144144
self::TOKEN_THIS_VARIABLE => '\\$this(?![0-9a-z_\\x80-\\xFF])',
145145
self::TOKEN_VARIABLE => '\\$[a-z_\\x80-\\xFF][0-9a-z_\\x80-\\xFF]*+',
146146

src/Parser/TypeParser.php

Lines changed: 146 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use PHPStan\PhpDocParser\Lexer\Lexer;
99
use PHPStan\PhpDocParser\ParserConfig;
1010
use function in_array;
11+
use function str_contains;
1112
use function str_replace;
1213
use function strlen;
1314
use function strpos;
@@ -113,29 +114,68 @@ private function subParse(TokenIterator $tokens): Ast\Type\TypeNode
113114
if ($tokens->isCurrentTokenType(Lexer::TOKEN_NULLABLE)) {
114115
$type = $this->parseNullable($tokens);
115116

116-
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) {
117-
$type = $this->parseConditionalForParameter($tokens, $tokens->currentTokenValue());
117+
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) {
118+
$propertyExpression = $this->parsePropertyExpression($tokens);
118119

119-
} else {
120-
$type = $this->parseAtomic($tokens);
120+
if ($propertyExpression !== null && $tokens->isCurrentTokenValue('is')) {
121+
$type = $this->parseConditionalForProperty($tokens, $propertyExpression);
122+
} else {
123+
$type = $this->parseAtomic($tokens);
124+
$type = $this->parseUnionOrIntersectionIfPresent($tokens, $type);
125+
}
121126

122-
if ($tokens->isCurrentTokenValue('is')) {
123-
$type = $this->parseConditional($tokens, $type);
127+
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) {
128+
$parameterName = $tokens->currentTokenValue();
129+
$propertyExpression = $this->parsePropertyExpression($tokens);
130+
131+
if ($propertyExpression !== null && $tokens->isCurrentTokenValue('is')) {
132+
$type = $this->parseConditionalForProperty($tokens, $propertyExpression);
124133
} else {
125-
$tokens->skipNewLineTokensAndConsumeComments();
134+
$type = $this->parseConditionalForParameter($tokens, $parameterName);
135+
}
126136

127-
if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) {
128-
$type = $this->subParseUnion($tokens, $type);
137+
} else {
138+
if ($tokens->isCurrentTokenValue('self') ||
139+
$tokens->isCurrentTokenValue('parent') ||
140+
$tokens->isCurrentTokenValue('static')) {
141+
$propertyExpression = $this->parsePropertyExpression($tokens);
129142

130-
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) {
131-
$type = $this->subParseIntersection($tokens, $type);
143+
if ($propertyExpression !== null && $tokens->isCurrentTokenValue('is')) {
144+
$type = $this->parseConditionalForProperty($tokens, $propertyExpression);
145+
} else {
146+
$type = $this->parseAtomic($tokens);
147+
$type = $this->parseUnionOrIntersectionIfPresent($tokens, $type);
132148
}
149+
} else {
150+
$type = $this->parseAtomic($tokens);
151+
$type = $this->parseUnionOrIntersectionIfPresent($tokens, $type);
133152
}
134153
}
135154

136155
return $this->enrichWithAttributes($tokens, $type, $startLine, $startIndex);
137156
}
138157

158+
/** @phpstan-impure */
159+
private function parseUnionOrIntersectionIfPresent(
160+
TokenIterator $tokens,
161+
Ast\Type\TypeNode $type
162+
): Ast\Type\TypeNode
163+
{
164+
if ($tokens->isCurrentTokenValue('is')) {
165+
return $this->parseConditional($tokens, $type);
166+
}
167+
168+
$tokens->skipNewLineTokensAndConsumeComments();
169+
170+
if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) {
171+
return $this->subParseUnion($tokens, $type);
172+
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) {
173+
return $this->subParseIntersection($tokens, $type);
174+
}
175+
176+
return $type;
177+
}
178+
139179
/** @phpstan-impure */
140180
private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode
141181
{
@@ -392,6 +432,101 @@ private function parseConditionalForParameter(TokenIterator $tokens, string $par
392432
return new Ast\Type\ConditionalTypeForParameterNode($parameterName, $targetType, $ifType, $elseType, $negated);
393433
}
394434

435+
/** @phpstan-impure */
436+
private function parsePropertyExpression(TokenIterator $tokens): ?string
437+
{
438+
$tokens->pushSavePoint();
439+
440+
// Handle $this->prop or $variable->prop
441+
if ($tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE) || $tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) {
442+
$expression = $tokens->currentTokenValue();
443+
if ($tokens->isCurrentTokenType(Lexer::TOKEN_THIS_VARIABLE)) {
444+
$tokens->consumeTokenType(Lexer::TOKEN_THIS_VARIABLE);
445+
} else {
446+
$tokens->consumeTokenType(Lexer::TOKEN_VARIABLE);
447+
}
448+
449+
// Parse chained property access: $this->a->b->c
450+
while ($tokens->isCurrentTokenType(Lexer::TOKEN_ARROW)) {
451+
$expression .= '->';
452+
$tokens->consumeTokenType(Lexer::TOKEN_ARROW);
453+
454+
if (!$tokens->isCurrentTokenType(Lexer::TOKEN_IDENTIFIER)) {
455+
$tokens->rollback();
456+
return null;
457+
}
458+
459+
$propertyName = $tokens->currentTokenValue();
460+
$tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
461+
$expression .= $propertyName;
462+
}
463+
464+
if (!str_contains($expression, '->')) {
465+
$tokens->rollback();
466+
return null;
467+
}
468+
469+
$tokens->dropSavePoint();
470+
return $expression;
471+
472+
} elseif ($tokens->isCurrentTokenValue('self') ||
473+
$tokens->isCurrentTokenValue('parent') ||
474+
$tokens->isCurrentTokenValue('static')) {
475+
$expression = $tokens->currentTokenValue();
476+
$tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
477+
478+
if (!$tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) {
479+
$tokens->rollback();
480+
return null;
481+
}
482+
483+
$expression .= '::';
484+
$tokens->consumeTokenType(Lexer::TOKEN_DOUBLE_COLON);
485+
486+
if (!$tokens->isCurrentTokenType(Lexer::TOKEN_VARIABLE)) {
487+
$tokens->rollback();
488+
return null;
489+
}
490+
491+
$expression .= $tokens->currentTokenValue();
492+
$tokens->consumeTokenType(Lexer::TOKEN_VARIABLE);
493+
494+
$tokens->dropSavePoint();
495+
return $expression;
496+
}
497+
498+
$tokens->dropSavePoint();
499+
return null;
500+
}
501+
502+
/** @phpstan-impure */
503+
private function parseConditionalForProperty(TokenIterator $tokens, string $propertyExpression): Ast\Type\TypeNode
504+
{
505+
$tokens->consumeTokenValue(Lexer::TOKEN_IDENTIFIER, 'is');
506+
507+
$negated = false;
508+
if ($tokens->isCurrentTokenValue('not')) {
509+
$negated = true;
510+
$tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
511+
}
512+
513+
$targetType = $this->parse($tokens);
514+
515+
$tokens->skipNewLineTokensAndConsumeComments();
516+
$tokens->consumeTokenType(Lexer::TOKEN_NULLABLE);
517+
$tokens->skipNewLineTokensAndConsumeComments();
518+
519+
$ifType = $this->parse($tokens);
520+
521+
$tokens->skipNewLineTokensAndConsumeComments();
522+
$tokens->consumeTokenType(Lexer::TOKEN_COLON);
523+
$tokens->skipNewLineTokensAndConsumeComments();
524+
525+
$elseType = $this->subParse($tokens);
526+
527+
return new Ast\Type\ConditionalTypeForPropertyNode($propertyExpression, $targetType, $ifType, $elseType, $negated);
528+
}
529+
395530
/** @phpstan-impure */
396531
private function parseNullable(TokenIterator $tokens): Ast\Type\TypeNode
397532
{

src/Printer/Printer.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
5252
use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
5353
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode;
54+
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForPropertyNode;
5455
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode;
5556
use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
5657
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
@@ -434,6 +435,16 @@ private function printType(TypeNode $node): string
434435
$this->printType($node->else),
435436
);
436437
}
438+
if ($node instanceof ConditionalTypeForPropertyNode) {
439+
return sprintf(
440+
'(%s %s %s ? %s : %s)',
441+
$node->propertyExpression,
442+
$node->negated ? 'is not' : 'is',
443+
$this->printType($node->targetType),
444+
$this->printType($node->if),
445+
$this->printType($node->else),
446+
);
447+
}
437448
if ($node instanceof ConditionalTypeNode) {
438449
return sprintf(
439450
'(%s %s %s ? %s : %s)',

0 commit comments

Comments
 (0)