Skip to content

Commit af5bf68

Browse files
gnutixclaude
andcommitted
Read a global constant name written with asterisks, like PHP_ROUND_*
A name followed by an asterisk with no space in between is now read as a ConstFetchNode with an empty class name and the pattern as its name, the way Foo::BAR_* already is for class constants: PHP_ROUND_*, E_*_ERROR, Foo\BAR_*. Such a PHPDoc was invalid until now, so nothing that parsed before parses differently. The asterisk has to touch the name, which is what keeps "@return string *Note:* text" a type followed by a description. The grammars tell an asterisk written right after a name apart as T_NAME_WILDCARD, so that they read the same language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent af8e840 commit af5bf68

12 files changed

Lines changed: 251 additions & 46 deletions

File tree

‎doc/grammars/common.pp3‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ Wildcard
5050
| <T_WILDCARD_WS>
5151
;
5252

53+
/**
54+
* An asterisk written right after a name, with no space in between, whether or
55+
* not whitespace follows it.
56+
*/
57+
NameWildcard
58+
: <T_NAME_WILDCARD>
59+
| <T_NAME_WILDCARD_WS>
60+
;
61+
5362
/**
5463
* A "(" whether or not whitespace precedes it.
5564
*/

‎doc/grammars/const-expr.pp3‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,24 @@ ConstantArrayItem
8686
* - Foo::BAR
8787
* - Foo::BAR_*
8888
* - BAR
89+
* - BAR_*
8990
*/
9091
ConstantFetch
9192
: !ArrayKeyword() Identifier() <T_DOUBLE_COLON> ClassConstantName()
92-
| !ArrayKeyword() Identifier()
93+
| !ArrayKeyword() Identifier() GlobalConstantNameAfterIdentifier()?
94+
;
95+
96+
/**
97+
* The rest of the name of a global constant written with asterisks standing
98+
* for any part of it, like the "*_ERROR" of:
99+
* - E_*_ERROR
100+
*
101+
* The first asterisk has to follow the name with no space in between, so that
102+
* "FOO *" stays the constant "FOO" followed by an asterisk.
103+
*/
104+
GlobalConstantNameAfterIdentifier
105+
: <T_NAME_WILDCARD_WS>
106+
| <T_NAME_WILDCARD> ConstantNameAfterWildcard()?
93107
;
94108

95109
/**
@@ -104,15 +118,21 @@ ConstantFetch
104118
*/
105119
ClassConstantName
106120
: <T_WILDCARD_WS>
107-
| <T_WILDCARD> ClassConstantNameAfterWildcard()?
108-
| Identifier() ClassConstantNameAfterIdentifier()?
121+
| <T_WILDCARD> ConstantNameAfterWildcard()?
122+
| Identifier() ConstantNameAfterIdentifier()?
109123
;
110124

111-
ClassConstantNameAfterWildcard
112-
: Identifier() ClassConstantNameAfterIdentifier()?
125+
ConstantNameAfterWildcard
126+
: Identifier() ConstantNameAfterIdentifier()?
113127
;
114128

115-
ClassConstantNameAfterIdentifier
129+
/**
130+
* An asterisk after a name goes on with the name of the constant, whether or not
131+
* there is a space between the two.
132+
*/
133+
ConstantNameAfterIdentifier
116134
: <T_WILDCARD_WS>
117-
| <T_WILDCARD> ClassConstantNameAfterWildcard()?
135+
| <T_WILDCARD> ConstantNameAfterWildcard()?
136+
| <T_NAME_WILDCARD_WS>
137+
| <T_NAME_WILDCARD> ConstantNameAfterWildcard()?
118138
;

‎doc/grammars/lexemes.pp3‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,16 @@
197197
* followed by something the type says nothing about;
198198
* - "Foo[0]" reads an offset while "Foo [0]" does not;
199199
* - "Foo::BAR*" goes on reading the name of the constant while "Foo::BAR* "
200-
* ends it.
200+
* ends it;
201+
* - "FOO*" is the name of a global constant written with an asterisk while
202+
* "FOO *" is the name "FOO" followed by an asterisk.
201203
*/
202204
%token T_OPEN_PARENTHESES_WS (?<=[\x09\x20])\(
203205
%token T_OPEN_CURLY_BRACKET_WS (?<=[\x09\x20])\{
204206
%token T_OPEN_SQUARE_BRACKET_WS (?<=[\x09\x20])\[
205207
%token T_WILDCARD_WS \*(?=[\x09\x20])
208+
%token T_NAME_WILDCARD (?<=[0-9a-z_\x80-\xFF-])\*(?![\x09\x20])
209+
%token T_NAME_WILDCARD_WS (?<=[0-9a-z_\x80-\xFF-])\*(?=[\x09\x20])
206210

207211
/**
208212
* A "<" opening what the hand-written parser recognizes as an HTML tag, that is

‎doc/grammars/phpdoc-block.pp3‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,8 @@ NonTagTextToken
554554
| <T_COLON>
555555
| <T_WILDCARD>
556556
| <T_WILDCARD_WS>
557+
| <T_NAME_WILDCARD>
558+
| <T_NAME_WILDCARD_WS>
557559
| <T_OPEN_PHPDOC>
558560
| <T_OTHER>
559561
;

‎doc/grammars/types.pp3‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,13 @@ Atomic
103103
/**
104104
* A name standing for a type rather than for the class of a constant.
105105
*
106-
* A name is only ever read as a constant expression when a "::" follows it, so
107-
* a name written without one is a type and stays a type: whatever the reading
106+
* A name is only ever read as a constant expression when a "::" follows it, or
107+
* an asterisk with no space in between, so a name written without one is a type and stays a type: whatever the reading
108108
* fails at after it is an error rather than a reason to read the name again as
109109
* something else.
110110
*/
111111
PlainName
112-
: Identifier() !<T_DOUBLE_COLON>
112+
: Identifier() !<T_DOUBLE_COLON> !NameWildcard()
113113
;
114114

115115
ParenthesizedAtomic
@@ -132,8 +132,8 @@ IdentifierAtomic
132132
: <T_KEYWORD_ARRAY> !<T_DOUBLE_COLON> ArrayShape() ArrayOrOffsetAccess()?
133133
| ListShapeIdentifier() !<T_DOUBLE_COLON> ListShape() ArrayOrOffsetAccess()?
134134
| <T_KEYWORD_OBJECT> !<T_DOUBLE_COLON> ObjectShape() ArrayOrOffsetAccess()?
135-
| ShapeIdentifier() !<T_DOUBLE_COLON> !<T_OPEN_CURLY_BRACKET> IdentifierTail()
136-
| PlainIdentifier() !<T_DOUBLE_COLON> IdentifierTail()
135+
| ShapeIdentifier() !<T_DOUBLE_COLON> !NameWildcard() !<T_OPEN_CURLY_BRACKET> IdentifierTail()
136+
| PlainIdentifier() !<T_DOUBLE_COLON> !NameWildcard() IdentifierTail()
137137
;
138138

139139
/**
@@ -358,8 +358,8 @@ CallableReturnType
358358
| <T_KEYWORD_ARRAY> !<T_DOUBLE_COLON> ArrayShape() ArrayOrOffsetAccess()?
359359
| ListShapeIdentifier() !<T_DOUBLE_COLON> ListShape() ArrayOrOffsetAccess()?
360360
| <T_KEYWORD_OBJECT> !<T_DOUBLE_COLON> ObjectShape() ArrayOrOffsetAccess()?
361-
| ShapeIdentifier() !<T_DOUBLE_COLON> !<T_OPEN_CURLY_BRACKET> CallableReturnTypeTail()
362-
| PlainIdentifier() !<T_DOUBLE_COLON> CallableReturnTypeTail()
361+
| ShapeIdentifier() !<T_DOUBLE_COLON> !NameWildcard() !<T_OPEN_CURLY_BRACKET> CallableReturnTypeTail()
362+
| PlainIdentifier() !<T_DOUBLE_COLON> !NameWildcard() CallableReturnTypeTail()
363363
| !PlainName() ConstantAtomic()
364364
;
365365

‎src/Parser/ConstExprParser.php‎

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -144,36 +144,18 @@ public function parse(TokenIterator $tokens): Ast\ConstExpr\ConstExprNode
144144
return $this->parseArray($tokens, Lexer::TOKEN_CLOSE_PARENTHESES, $startIndex);
145145
}
146146

147-
if ($tokens->tryConsumeTokenType(Lexer::TOKEN_DOUBLE_COLON)) {
148-
$classConstantName = '';
149-
$lastType = null;
150-
while (true) {
151-
if ($lastType !== Lexer::TOKEN_IDENTIFIER && $tokens->currentTokenType() === Lexer::TOKEN_IDENTIFIER) {
152-
$classConstantName .= $tokens->currentTokenValue();
153-
$tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
154-
$lastType = Lexer::TOKEN_IDENTIFIER;
155-
156-
continue;
157-
}
158-
159-
if ($lastType !== Lexer::TOKEN_WILDCARD && $tokens->tryConsumeTokenType(Lexer::TOKEN_WILDCARD)) {
160-
$classConstantName .= '*';
161-
$lastType = Lexer::TOKEN_WILDCARD;
162-
163-
if ($tokens->getSkippedHorizontalWhiteSpaceIfAny() !== '') {
164-
break;
165-
}
166-
167-
continue;
168-
}
169-
170-
if ($lastType === null) {
171-
// trigger parse error if nothing valid was consumed
172-
$tokens->consumeTokenType(Lexer::TOKEN_WILDCARD);
173-
}
147+
if ($tokens->isCurrentTokenType(Lexer::TOKEN_WILDCARD) && !$tokens->isPrecededByHorizontalWhitespace()) {
148+
// a global constant name pattern like PHP_ROUND_*
149+
return $this->enrichWithAttributes(
150+
$tokens,
151+
new Ast\ConstExpr\ConstFetchNode('', $this->parseConstantNamePattern($tokens, $identifier, Lexer::TOKEN_IDENTIFIER)),
152+
$startLine,
153+
$startIndex,
154+
);
155+
}
174156

175-
break;
176-
}
157+
if ($tokens->tryConsumeTokenType(Lexer::TOKEN_DOUBLE_COLON)) {
158+
$classConstantName = $this->parseConstantNamePattern($tokens, '', null);
177159

178160
return $this->enrichWithAttributes(
179161
$tokens,
@@ -205,6 +187,44 @@ public function parse(TokenIterator $tokens): Ast\ConstExpr\ConstExprNode
205187
);
206188
}
207189

190+
/**
191+
* Reads the rest of a constant name in which * stands for any sequence of characters.
192+
*
193+
* @param Lexer::TOKEN_IDENTIFIER|Lexer::TOKEN_WILDCARD|null $lastType the token $name ends with, null when it is empty
194+
*/
195+
private function parseConstantNamePattern(TokenIterator $tokens, string $name, ?int $lastType): string
196+
{
197+
while (true) {
198+
if ($lastType !== Lexer::TOKEN_IDENTIFIER && $tokens->currentTokenType() === Lexer::TOKEN_IDENTIFIER) {
199+
$name .= $tokens->currentTokenValue();
200+
$tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
201+
$lastType = Lexer::TOKEN_IDENTIFIER;
202+
203+
continue;
204+
}
205+
206+
if ($lastType !== Lexer::TOKEN_WILDCARD && $tokens->tryConsumeTokenType(Lexer::TOKEN_WILDCARD)) {
207+
$name .= '*';
208+
$lastType = Lexer::TOKEN_WILDCARD;
209+
210+
if ($tokens->getSkippedHorizontalWhiteSpaceIfAny() !== '') {
211+
break;
212+
}
213+
214+
continue;
215+
}
216+
217+
if ($lastType === null) {
218+
// trigger parse error if nothing valid was consumed
219+
$tokens->consumeTokenType(Lexer::TOKEN_WILDCARD);
220+
}
221+
222+
break;
223+
}
224+
225+
return $name;
226+
}
227+
208228
private function parseArray(TokenIterator $tokens, int $endToken, int $startIndex): Ast\ConstExpr\ConstExprArrayNode
209229
{
210230
$items = [];

‎src/Parser/TypeParser.php‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode
171171
if ($tokens->tryConsumeTokenType(Lexer::TOKEN_IDENTIFIER)) {
172172
$type = $this->enrichWithAttributes($tokens, new Ast\Type\IdentifierTypeNode($currentTokenValue), $startLine, $startIndex);
173173

174-
if (!$tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) {
174+
if (!$this->isConstantFetchAfterName($tokens)) {
175175
$tokens->dropSavePoint(); // because of ConstFetchNode
176176
if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) {
177177
$tokens->pushSavePoint();
@@ -267,6 +267,20 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode
267267
}
268268
}
269269

270+
/**
271+
* Whether the name just read starts a constant expression rather than a type: a class constant
272+
* like Foo::BAR, or a global constant pattern like FOO_* whose asterisk follows the name with
273+
* no space in between.
274+
*/
275+
private function isConstantFetchAfterName(TokenIterator $tokens): bool
276+
{
277+
if ($tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) {
278+
return true;
279+
}
280+
281+
return $tokens->isCurrentTokenType(Lexer::TOKEN_WILDCARD) && !$tokens->isPrecededByHorizontalWhitespace();
282+
}
283+
270284
/** @phpstan-impure */
271285
private function parseUnion(TokenIterator $tokens, Ast\Type\TypeNode $type): Ast\Type\TypeNode
272286
{
@@ -692,7 +706,7 @@ private function parseCallableReturnType(TokenIterator $tokens): Ast\Type\TypeNo
692706
if ($tokens->tryConsumeTokenType(Lexer::TOKEN_IDENTIFIER)) {
693707
$type = new Ast\Type\IdentifierTypeNode($currentTokenValue);
694708

695-
if (!$tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) {
709+
if (!$this->isConstantFetchAfterName($tokens)) {
696710
if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) {
697711
$type = $this->parseGeneric(
698712
$tokens,

‎tests/PHPStan/Parser/ConstExprParserTest.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,21 @@ public function provideFetchNodeParseData(): Iterator
454454
'self::CLASS_CONSTANT',
455455
new ConstFetchNode('self', 'CLASS_CONSTANT'),
456456
];
457+
458+
yield [
459+
'GLOBAL_*',
460+
new ConstFetchNode('', 'GLOBAL_*'),
461+
];
462+
463+
yield [
464+
'GLOBAL_*_CONSTANT',
465+
new ConstFetchNode('', 'GLOBAL_*_CONSTANT'),
466+
];
467+
468+
yield [
469+
'Foo\\Bar\\GLOBAL_*',
470+
new ConstFetchNode('', 'Foo\\Bar\\GLOBAL_*'),
471+
];
457472
}
458473

459474
}

‎tests/PHPStan/Parser/PhpDocParserTest.php‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,23 @@ public function provideParamTagsData(): Iterator
196196
]),
197197
];
198198

199+
yield [
200+
'OK with a global constant pattern',
201+
'/** @param PHP_ROUND_* $mode */',
202+
new PhpDocNode([
203+
new PhpDocTagNode(
204+
'@param',
205+
new ParamTagValueNode(
206+
new ConstTypeNode(new ConstFetchNode('', 'PHP_ROUND_*')),
207+
false,
208+
'$mode',
209+
'',
210+
false,
211+
),
212+
),
213+
]),
214+
];
215+
199216
yield [
200217
'OK variadic without description',
201218
'/** @param Foo ...$foo */',
@@ -1392,6 +1409,37 @@ public function providePropertyTagsData(): Iterator
13921409

13931410
public function provideReturnTagsData(): Iterator
13941411
{
1412+
yield [
1413+
'OK with a global constant pattern',
1414+
'/** @return PHP_ROUND_*|null */',
1415+
new PhpDocNode([
1416+
new PhpDocTagNode(
1417+
'@return',
1418+
new ReturnTagValueNode(
1419+
new UnionTypeNode([
1420+
new ConstTypeNode(new ConstFetchNode('', 'PHP_ROUND_*')),
1421+
new IdentifierTypeNode('null'),
1422+
]),
1423+
'',
1424+
),
1425+
),
1426+
]),
1427+
];
1428+
1429+
yield [
1430+
'OK with an asterisk opening the description',
1431+
'/** @return string *emphasized* description */',
1432+
new PhpDocNode([
1433+
new PhpDocTagNode(
1434+
'@return',
1435+
new ReturnTagValueNode(
1436+
new IdentifierTypeNode('string'),
1437+
'*emphasized* description',
1438+
),
1439+
),
1440+
]),
1441+
];
1442+
13951443
yield [
13961444
'OK without description',
13971445
'/** @return Foo */',

0 commit comments

Comments
 (0)