diff --git a/doc/grammars/common.pp3 b/doc/grammars/common.pp3 index b0d9d0c8..ffcdd8b3 100644 --- a/doc/grammars/common.pp3 +++ b/doc/grammars/common.pp3 @@ -50,6 +50,15 @@ Wildcard | ; +/** + * An asterisk written right after a name, with no space in between, whether or + * not whitespace follows it. + */ +NameWildcard + : + | + ; + /** * A "(" whether or not whitespace precedes it. */ diff --git a/doc/grammars/const-expr.pp3 b/doc/grammars/const-expr.pp3 index d368bf7b..7bf5fcf3 100644 --- a/doc/grammars/const-expr.pp3 +++ b/doc/grammars/const-expr.pp3 @@ -86,10 +86,24 @@ ConstantArrayItem * - Foo::BAR * - Foo::BAR_* * - BAR + * - BAR_* */ ConstantFetch : !ArrayKeyword() Identifier() ClassConstantName() - | !ArrayKeyword() Identifier() + | !ArrayKeyword() Identifier() GlobalConstantNameAfterIdentifier()? + ; + +/** + * The rest of the name of a global constant written with asterisks standing + * for any part of it, like the "*_ERROR" of: + * - E_*_ERROR + * + * The first asterisk has to follow the name with no space in between, so that + * "FOO *" stays the constant "FOO" followed by an asterisk. + */ +GlobalConstantNameAfterIdentifier + : + | ConstantNameAfterWildcard()? ; /** @@ -104,15 +118,21 @@ ConstantFetch */ ClassConstantName : - | ClassConstantNameAfterWildcard()? - | Identifier() ClassConstantNameAfterIdentifier()? + | ConstantNameAfterWildcard()? + | Identifier() ConstantNameAfterIdentifier()? ; -ClassConstantNameAfterWildcard - : Identifier() ClassConstantNameAfterIdentifier()? +ConstantNameAfterWildcard + : Identifier() ConstantNameAfterIdentifier()? ; -ClassConstantNameAfterIdentifier +/** + * An asterisk after a name goes on with the name of the constant, whether or not + * there is a space between the two. + */ +ConstantNameAfterIdentifier : - | ClassConstantNameAfterWildcard()? + | ConstantNameAfterWildcard()? + | + | ConstantNameAfterWildcard()? ; diff --git a/doc/grammars/lexemes.pp3 b/doc/grammars/lexemes.pp3 index d69176ff..0a572f87 100644 --- a/doc/grammars/lexemes.pp3 +++ b/doc/grammars/lexemes.pp3 @@ -197,12 +197,16 @@ * followed by something the type says nothing about; * - "Foo[0]" reads an offset while "Foo [0]" does not; * - "Foo::BAR*" goes on reading the name of the constant while "Foo::BAR* " - * ends it. + * ends it; + * - "FOO*" is the name of a global constant written with an asterisk while + * "FOO *" is the name "FOO" followed by an asterisk. */ %token T_OPEN_PARENTHESES_WS (?<=[\x09\x20])\( %token T_OPEN_CURLY_BRACKET_WS (?<=[\x09\x20])\{ %token T_OPEN_SQUARE_BRACKET_WS (?<=[\x09\x20])\[ %token T_WILDCARD_WS \*(?=[\x09\x20]) +%token T_NAME_WILDCARD (?<=[0-9a-z_\x80-\xFF-])\*(?![\x09\x20]) +%token T_NAME_WILDCARD_WS (?<=[0-9a-z_\x80-\xFF-])\*(?=[\x09\x20]) /** * A "<" opening what the hand-written parser recognizes as an HTML tag, that is diff --git a/doc/grammars/phpdoc-block.pp3 b/doc/grammars/phpdoc-block.pp3 index bbb74384..2ace5c82 100644 --- a/doc/grammars/phpdoc-block.pp3 +++ b/doc/grammars/phpdoc-block.pp3 @@ -554,6 +554,8 @@ NonTagTextToken | | | + | + | | | ; diff --git a/doc/grammars/types.pp3 b/doc/grammars/types.pp3 index a1b92aeb..98b69085 100644 --- a/doc/grammars/types.pp3 +++ b/doc/grammars/types.pp3 @@ -103,13 +103,13 @@ Atomic /** * A name standing for a type rather than for the class of a constant. * - * A name is only ever read as a constant expression when a "::" follows it, so - * a name written without one is a type and stays a type: whatever the reading + * A name is only ever read as a constant expression when a "::" follows it, or + * an asterisk with no space in between, so a name written without one is a type and stays a type: whatever the reading * fails at after it is an error rather than a reason to read the name again as * something else. */ PlainName - : Identifier() ! + : Identifier() ! !NameWildcard() ; ParenthesizedAtomic @@ -132,8 +132,8 @@ IdentifierAtomic : ! ArrayShape() ArrayOrOffsetAccess()? | ListShapeIdentifier() ! ListShape() ArrayOrOffsetAccess()? | ! ObjectShape() ArrayOrOffsetAccess()? - | ShapeIdentifier() ! ! IdentifierTail() - | PlainIdentifier() ! IdentifierTail() + | ShapeIdentifier() ! !NameWildcard() ! IdentifierTail() + | PlainIdentifier() ! !NameWildcard() IdentifierTail() ; /** @@ -358,8 +358,8 @@ CallableReturnType | ! ArrayShape() ArrayOrOffsetAccess()? | ListShapeIdentifier() ! ListShape() ArrayOrOffsetAccess()? | ! ObjectShape() ArrayOrOffsetAccess()? - | ShapeIdentifier() ! ! CallableReturnTypeTail() - | PlainIdentifier() ! CallableReturnTypeTail() + | ShapeIdentifier() ! !NameWildcard() ! CallableReturnTypeTail() + | PlainIdentifier() ! !NameWildcard() CallableReturnTypeTail() | !PlainName() ConstantAtomic() ; diff --git a/src/Parser/ConstExprParser.php b/src/Parser/ConstExprParser.php index dd08cfd4..975ff9f8 100644 --- a/src/Parser/ConstExprParser.php +++ b/src/Parser/ConstExprParser.php @@ -144,36 +144,18 @@ public function parse(TokenIterator $tokens): Ast\ConstExpr\ConstExprNode return $this->parseArray($tokens, Lexer::TOKEN_CLOSE_PARENTHESES, $startIndex); } - if ($tokens->tryConsumeTokenType(Lexer::TOKEN_DOUBLE_COLON)) { - $classConstantName = ''; - $lastType = null; - while (true) { - if ($lastType !== Lexer::TOKEN_IDENTIFIER && $tokens->currentTokenType() === Lexer::TOKEN_IDENTIFIER) { - $classConstantName .= $tokens->currentTokenValue(); - $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); - $lastType = Lexer::TOKEN_IDENTIFIER; - - continue; - } - - if ($lastType !== Lexer::TOKEN_WILDCARD && $tokens->tryConsumeTokenType(Lexer::TOKEN_WILDCARD)) { - $classConstantName .= '*'; - $lastType = Lexer::TOKEN_WILDCARD; - - if ($tokens->getSkippedHorizontalWhiteSpaceIfAny() !== '') { - break; - } - - continue; - } - - if ($lastType === null) { - // trigger parse error if nothing valid was consumed - $tokens->consumeTokenType(Lexer::TOKEN_WILDCARD); - } + if ($tokens->isCurrentTokenType(Lexer::TOKEN_WILDCARD) && !$tokens->isPrecededByHorizontalWhitespace()) { + // a global constant name pattern like PHP_ROUND_* + return $this->enrichWithAttributes( + $tokens, + new Ast\ConstExpr\ConstFetchNode('', $this->parseConstantNamePattern($tokens, $identifier, Lexer::TOKEN_IDENTIFIER)), + $startLine, + $startIndex, + ); + } - break; - } + if ($tokens->tryConsumeTokenType(Lexer::TOKEN_DOUBLE_COLON)) { + $classConstantName = $this->parseConstantNamePattern($tokens, '', null); return $this->enrichWithAttributes( $tokens, @@ -205,6 +187,44 @@ public function parse(TokenIterator $tokens): Ast\ConstExpr\ConstExprNode ); } + /** + * Reads the rest of a constant name in which * stands for any sequence of characters. + * + * @param Lexer::TOKEN_IDENTIFIER|Lexer::TOKEN_WILDCARD|null $lastType the token $name ends with, null when it is empty + */ + private function parseConstantNamePattern(TokenIterator $tokens, string $name, ?int $lastType): string + { + while (true) { + if ($lastType !== Lexer::TOKEN_IDENTIFIER && $tokens->currentTokenType() === Lexer::TOKEN_IDENTIFIER) { + $name .= $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + $lastType = Lexer::TOKEN_IDENTIFIER; + + continue; + } + + if ($lastType !== Lexer::TOKEN_WILDCARD && $tokens->tryConsumeTokenType(Lexer::TOKEN_WILDCARD)) { + $name .= '*'; + $lastType = Lexer::TOKEN_WILDCARD; + + if ($tokens->getSkippedHorizontalWhiteSpaceIfAny() !== '') { + break; + } + + continue; + } + + if ($lastType === null) { + // trigger parse error if nothing valid was consumed + $tokens->consumeTokenType(Lexer::TOKEN_WILDCARD); + } + + break; + } + + return $name; + } + private function parseArray(TokenIterator $tokens, int $endToken, int $startIndex): Ast\ConstExpr\ConstExprArrayNode { $items = []; diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 348c56d1..877cc866 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -171,7 +171,7 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode if ($tokens->tryConsumeTokenType(Lexer::TOKEN_IDENTIFIER)) { $type = $this->enrichWithAttributes($tokens, new Ast\Type\IdentifierTypeNode($currentTokenValue), $startLine, $startIndex); - if (!$tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) { + if (!$this->isConstantFetchAfterName($tokens)) { $tokens->dropSavePoint(); // because of ConstFetchNode if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { $tokens->pushSavePoint(); @@ -267,6 +267,20 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode } } + /** + * Whether the name just read starts a constant expression rather than a type: a class constant + * like Foo::BAR, or a global constant pattern like FOO_* whose asterisk follows the name with + * no space in between. + */ + private function isConstantFetchAfterName(TokenIterator $tokens): bool + { + if ($tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) { + return true; + } + + return $tokens->isCurrentTokenType(Lexer::TOKEN_WILDCARD) && !$tokens->isPrecededByHorizontalWhitespace(); + } + /** @phpstan-impure */ private function parseUnion(TokenIterator $tokens, Ast\Type\TypeNode $type): Ast\Type\TypeNode { @@ -692,7 +706,7 @@ private function parseCallableReturnType(TokenIterator $tokens): Ast\Type\TypeNo if ($tokens->tryConsumeTokenType(Lexer::TOKEN_IDENTIFIER)) { $type = new Ast\Type\IdentifierTypeNode($currentTokenValue); - if (!$tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) { + if (!$this->isConstantFetchAfterName($tokens)) { if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { $type = $this->parseGeneric( $tokens, diff --git a/tests/PHPStan/Parser/ConstExprParserTest.php b/tests/PHPStan/Parser/ConstExprParserTest.php index 1fb0f30a..59a46bdb 100644 --- a/tests/PHPStan/Parser/ConstExprParserTest.php +++ b/tests/PHPStan/Parser/ConstExprParserTest.php @@ -454,6 +454,21 @@ public function provideFetchNodeParseData(): Iterator 'self::CLASS_CONSTANT', new ConstFetchNode('self', 'CLASS_CONSTANT'), ]; + + yield [ + 'GLOBAL_*', + new ConstFetchNode('', 'GLOBAL_*'), + ]; + + yield [ + 'GLOBAL_*_CONSTANT', + new ConstFetchNode('', 'GLOBAL_*_CONSTANT'), + ]; + + yield [ + 'Foo\\Bar\\GLOBAL_*', + new ConstFetchNode('', 'Foo\\Bar\\GLOBAL_*'), + ]; } } diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index b9b59ba6..746c312d 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -196,6 +196,23 @@ public function provideParamTagsData(): Iterator ]), ]; + yield [ + 'OK with a global constant pattern', + '/** @param PHP_ROUND_* $mode */', + new PhpDocNode([ + new PhpDocTagNode( + '@param', + new ParamTagValueNode( + new ConstTypeNode(new ConstFetchNode('', 'PHP_ROUND_*')), + false, + '$mode', + '', + false, + ), + ), + ]), + ]; + yield [ 'OK variadic without description', '/** @param Foo ...$foo */', @@ -1392,6 +1409,37 @@ public function providePropertyTagsData(): Iterator public function provideReturnTagsData(): Iterator { + yield [ + 'OK with a global constant pattern', + '/** @return PHP_ROUND_*|null */', + new PhpDocNode([ + new PhpDocTagNode( + '@return', + new ReturnTagValueNode( + new UnionTypeNode([ + new ConstTypeNode(new ConstFetchNode('', 'PHP_ROUND_*')), + new IdentifierTypeNode('null'), + ]), + '', + ), + ), + ]), + ]; + + yield [ + 'OK with an asterisk opening the description', + '/** @return string *emphasized* description */', + new PhpDocNode([ + new PhpDocTagNode( + '@return', + new ReturnTagValueNode( + new IdentifierTypeNode('string'), + '*emphasized* description', + ), + ), + ]), + ]; + yield [ 'OK without description', '/** @return Foo */', diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php index 1f97651d..5562dce9 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -1981,6 +1981,55 @@ public function provideParseData(): array 'Foo::*a', new ConstTypeNode(new ConstFetchNode('Foo', '*a')), ], + [ + 'FOO_*', + new ConstTypeNode(new ConstFetchNode('', 'FOO_*')), + ], + [ + 'FOO_*BAR', + new ConstTypeNode(new ConstFetchNode('', 'FOO_*BAR')), + ], + [ + 'E_*_ERROR', + new ConstTypeNode(new ConstFetchNode('', 'E_*_ERROR')), + ], + [ + 'A*B*C', + new ConstTypeNode(new ConstFetchNode('', 'A*B*C')), + ], + [ + '\\Foo\\BAR_*', + new ConstTypeNode(new ConstFetchNode('', '\\Foo\\BAR_*')), + ], + [ + 'Foo\\BAR_*', + new ConstTypeNode(new ConstFetchNode('', 'Foo\\BAR_*')), + ], + [ + 'FOO**', + new ConstTypeNode(new ConstFetchNode('', 'FOO*')), // fails later in PhpDocParser + Lexer::TOKEN_WILDCARD, + ], + [ + 'FOO *', + new IdentifierTypeNode('FOO'), // the asterisk has to follow the name with no space in between + Lexer::TOKEN_WILDCARD, + ], + [ + 'PHP_ROUND_*|null', + new UnionTypeNode([ + new ConstTypeNode(new ConstFetchNode('', 'PHP_ROUND_*')), + new IdentifierTypeNode('null'), + ]), + ], + [ + 'list', + new GenericTypeNode(new IdentifierTypeNode('list'), [ + new ConstTypeNode(new ConstFetchNode('', 'JSON_*')), + ], [ + GenericTypeNode::VARIANCE_INVARIANT, + ]), + ], [ '( "foo" | Foo::FOO_* )', new UnionTypeNode([ @@ -3046,6 +3095,10 @@ public function provideParseData(): array 'callable(): Foo::*', new CallableTypeNode(new IdentifierTypeNode('callable'), [], new ConstTypeNode(new ConstFetchNode('Foo', '*')), []), ], + [ + 'callable(): FOO_*', + new CallableTypeNode(new IdentifierTypeNode('callable'), [], new ConstTypeNode(new ConstFetchNode('', 'FOO_*')), []), + ], [ '?Foo[]', new NullableTypeNode(new ArrayTypeNode(new IdentifierTypeNode('Foo'))), diff --git a/tools/phplrt/Fuzzer/Generator.php b/tools/phplrt/Fuzzer/Generator.php index 6dc344e2..49a2e052 100644 --- a/tools/phplrt/Fuzzer/Generator.php +++ b/tools/phplrt/Fuzzer/Generator.php @@ -105,6 +105,8 @@ final class Generator 'T_OPEN_CURLY_BRACKET_WS' => ['{'], 'T_OPEN_SQUARE_BRACKET_WS' => ['['], 'T_WILDCARD_WS' => ['*'], + 'T_NAME_WILDCARD' => ['*'], + 'T_NAME_WILDCARD_WS' => ['*'], 'T_OPEN_ANGLE_BRACKET_HTML' => ['<'], 'T_TAG_PARAM' => ['@param', '@phpstan-param'], @@ -147,6 +149,8 @@ final class Generator 'T_OPEN_SQUARE_BRACKET' => '', 'T_OPEN_CURLY_BRACKET_WS' => ' ', 'T_OPEN_SQUARE_BRACKET_WS' => ' ', + 'T_NAME_WILDCARD' => '', + 'T_NAME_WILDCARD_WS' => '', 'T_PHPDOC_EOL' => '', ]; @@ -156,6 +160,8 @@ final class Generator private const SPACING_AFTER = [ 'T_WILDCARD' => '', 'T_WILDCARD_WS' => ' ', + 'T_NAME_WILDCARD' => '', + 'T_NAME_WILDCARD_WS' => ' ', 'T_PHPDOC_EOL' => '', ]; diff --git a/tools/phplrt/Fuzzer/TokenStream.php b/tools/phplrt/Fuzzer/TokenStream.php index e3f75441..78aa1380 100644 --- a/tools/phplrt/Fuzzer/TokenStream.php +++ b/tools/phplrt/Fuzzer/TokenStream.php @@ -353,6 +353,10 @@ private function identify(array $tokens, int $index, int $type, string $value): return $this->id(self::isPrecededByWhitespace($tokens, $index) ? 'T_OPEN_SQUARE_BRACKET_WS' : 'T_OPEN_SQUARE_BRACKET'); case Lexer::TOKEN_WILDCARD: + if (self::isPrecededByName($tokens, $index)) { + return $this->id(self::isFollowedByWhitespace($tokens, $index) ? 'T_NAME_WILDCARD_WS' : 'T_NAME_WILDCARD'); + } + return $this->id(self::isFollowedByWhitespace($tokens, $index) ? 'T_WILDCARD_WS' : 'T_WILDCARD'); case Lexer::TOKEN_OPEN_ANGLE_BRACKET: @@ -371,6 +375,16 @@ private static function isPrecededByWhitespace(array $tokens, int $index): bool return ($tokens[$index - 1][Lexer::TYPE_OFFSET] ?? -1) === Lexer::TOKEN_HORIZONTAL_WS; } + /** + * Whether a name comes right before the token, with no whitespace in between. + * + * @param list $tokens + */ + private static function isPrecededByName(array $tokens, int $index): bool + { + return ($tokens[$index - 1][Lexer::TYPE_OFFSET] ?? -1) === Lexer::TOKEN_IDENTIFIER; + } + /** * @param list $tokens */