Skip to content

Commit 1cd9224

Browse files
committed
Tokenizer/PHP: improved tokenization of fully qualified exit/die/true/false/null
As described in more detail in 1201, if the `exit`/`die`/`true`/`false`/`null` keywords were used in their fully qualified form, this was previously tokenized as `T_NS_SEPARATOR` + `T_STRING`, which was rarely, if ever, handled correctly by sniffs. This commit now changes the tokenization of fully qualified `exit`/`die`/`true`/`false`/`null` to be `T_NS_SEPARATOR` + the relevant dedicated token, i.e `T_EXIT`/`T_TRUE`/`T_FALSE` or `T_NULL`. Includes plenty of tests, including tests to safeguard against regressions which could be caused by this change in the "context sensitive keywords" layer and the "undo PHP 8.0+ namespaced names" layer. Fixes 1201 (for PHPCS 3.x).
1 parent ca606d9 commit 1cd9224

17 files changed

+1104
-23
lines changed

src/Tokenizers/PHP.php

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,25 @@ protected function tokenize($string)
694694
break;
695695
}
696696
}
697+
698+
// Fully Qualified `\exit`/`\die` should be preserved.
699+
if ($token[0] === T_EXIT
700+
&& $finalTokens[$lastNotEmptyToken]['code'] === T_NS_SEPARATOR
701+
) {
702+
for ($i = ($lastNotEmptyToken - 1); $i >= 0; $i--) {
703+
if (isset(Tokens::$emptyTokens[$finalTokens[$i]['code']]) === true) {
704+
continue;
705+
}
706+
707+
if ($finalTokens[$i]['code'] !== T_STRING
708+
&& $finalTokens[$i]['code'] !== T_NAMESPACE
709+
) {
710+
$preserveKeyword = true;
711+
}
712+
713+
break;
714+
}
715+
}
697716
}//end if
698717

699718
// Types in typed constants should not be touched, but the constant name should be.
@@ -1355,6 +1374,38 @@ protected function tokenize($string)
13551374
$name = substr($name, 10);
13561375
}
13571376

1377+
// Special case keywords which can be used in fully qualified form.
1378+
if ($token[0] === T_NAME_FULLY_QUALIFIED) {
1379+
$specialCasedType = null;
1380+
$nameLc = strtolower($name);
1381+
if ($nameLc === 'exit' || $nameLc === 'die') {
1382+
$specialCasedType = 'T_EXIT';
1383+
} else if ($nameLc === 'true') {
1384+
$specialCasedType = 'T_TRUE';
1385+
} else if ($nameLc === 'false') {
1386+
$specialCasedType = 'T_FALSE';
1387+
} else if ($nameLc === 'null') {
1388+
$specialCasedType = 'T_NULL';
1389+
}
1390+
1391+
if ($specialCasedType !== null) {
1392+
$newToken = [];
1393+
$newToken['code'] = constant($specialCasedType);
1394+
$newToken['type'] = $specialCasedType;
1395+
$newToken['content'] = $name;
1396+
$finalTokens[$newStackPtr] = $newToken;
1397+
++$newStackPtr;
1398+
1399+
if (PHP_CODESNIFFER_VERBOSITY > 1) {
1400+
$type = Tokens::tokenName($token[0]);
1401+
$content = Common::prepareForOutput($token[1]);
1402+
echo "\t\t* token $stackPtr split into individual tokens T_NS_SEPARATOR + $specialCasedType".PHP_EOL;
1403+
}
1404+
1405+
continue;
1406+
}
1407+
}//end if
1408+
13581409
$parts = explode('\\', $name);
13591410
$partCount = count($parts);
13601411
$lastPart = ($partCount - 1);
@@ -2523,16 +2574,37 @@ function return types. We want to keep the parenthesis map clean,
25232574
} else if (isset($this->tstringContexts[$finalTokens[$lastNotEmptyToken]['code']]) === true
25242575
&& $finalTokens[$lastNotEmptyToken]['code'] !== T_CONST
25252576
) {
2526-
$preserveTstring = true;
2577+
$preserveTstring = true;
2578+
$tokenContentLower = strtolower($token[1]);
25272579

25282580
// Special case for syntax like: return new self/new parent
25292581
// where self/parent should not be a string.
2530-
$tokenContentLower = strtolower($token[1]);
25312582
if ($finalTokens[$lastNotEmptyToken]['code'] === T_NEW
25322583
&& ($tokenContentLower === 'self' || $tokenContentLower === 'parent')
25332584
) {
25342585
$preserveTstring = false;
25352586
}
2587+
2588+
// Special case for fully qualified \true, \false and \null
2589+
// where true/false/null should not be a string.
2590+
// Note: if this is the _start_ of a longer namespaced name, this will undone again later.
2591+
if ($finalTokens[$lastNotEmptyToken]['code'] === T_NS_SEPARATOR
2592+
&& ($tokenContentLower === 'true' || $tokenContentLower === 'false' || $tokenContentLower === 'null')
2593+
) {
2594+
for ($i = ($lastNotEmptyToken - 1); $i >= 0; $i--) {
2595+
if (isset(Tokens::$emptyTokens[$finalTokens[$i]['code']]) === true) {
2596+
continue;
2597+
}
2598+
2599+
if ($finalTokens[$i]['code'] !== T_STRING
2600+
&& $finalTokens[$i]['code'] !== T_NAMESPACE
2601+
) {
2602+
$preserveTstring = false;
2603+
}
2604+
2605+
break;
2606+
}
2607+
}
25362608
} else if ($finalTokens[$lastNotEmptyToken]['content'] === '&') {
25372609
// Function names for functions declared to return by reference.
25382610
for ($i = ($lastNotEmptyToken - 1); $i >= 0; $i--) {
@@ -3629,6 +3701,7 @@ protected function processAdditional()
36293701
} else if ($this->tokens[$i]['code'] === T_TRUE
36303702
|| $this->tokens[$i]['code'] === T_FALSE
36313703
|| $this->tokens[$i]['code'] === T_NULL
3704+
|| $this->tokens[$i]['code'] === T_EXIT
36323705
) {
36333706
for ($x = ($i + 1); $x < $numTokens; $x++) {
36343707
if (isset(Tokens::$emptyTokens[$this->tokens[$x]['code']]) === false) {

tests/Core/Files/File/FindStartOfStatementTest.inc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ switch ($foo) {
162162
/* testInsideCaseGotoStatement */
163163
goto myLabel;
164164

165+
case 7:
166+
/* testInsideCaseFullyQualifiedDieStatement */
167+
\die(1);
168+
165169
/* testDefaultStatement */
166170
default:
167171
/* testInsideDefaultContinueStatement */

tests/Core/Files/File/FindStartOfStatementTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,12 @@ public static function dataFindStartInsideSwitchCaseDefaultStatements()
669669
'targets' => T_SEMICOLON,
670670
'expectedTarget' => T_GOTO,
671671
],
672+
'Namespace separator for "die" should be start for contents - close parenthesis' => [
673+
// Note: not sure if this is actually correct - should this be the open parenthesis ?
674+
'testMarker' => '/* testInsideCaseFullyQualifiedDieStatement */',
675+
'targets' => T_CLOSE_PARENTHESIS,
676+
'expectedTarget' => T_NS_SEPARATOR,
677+
],
672678
'Default keyword should be start of default statement - default itself' => [
673679
'testMarker' => '/* testDefaultStatement */',
674680
'targets' => T_DEFAULT,

tests/Core/Tokenizers/PHP/ContextSensitiveKeywordsTest.inc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ $a = isset($a);
186186
/* testUnsetIsKeyword */
187187
unset($a);
188188

189+
/* testFullyQualifiedDieIsKeyword */
190+
\die;
191+
/* testFullyQualifiedExitIsKeyword */
192+
\exit($foo);
193+
189194
/* testIncludeIsKeyword */
190195
include 'file.php';
191196
/* testIncludeOnceIsKeyword */

tests/Core/Tokenizers/PHP/ContextSensitiveKeywordsTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,14 @@ public static function dataKeywords()
449449
'testMarker' => '/* testUnsetIsKeyword */',
450450
'expectedTokenType' => 'T_UNSET',
451451
],
452+
'\\die: statement (fully qualified)' => [
453+
'testMarker' => '/* testFullyQualifiedDieIsKeyword */',
454+
'expectedTokenType' => 'T_EXIT',
455+
],
456+
'\\exit: statement (fully qualified)' => [
457+
'testMarker' => '/* testFullyQualifiedExitIsKeyword */',
458+
'expectedTokenType' => 'T_EXIT',
459+
],
452460

453461
'include' => [
454462
'testMarker' => '/* testIncludeIsKeyword */',

tests/Core/Tokenizers/PHP/EnumCaseTest.inc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,6 @@ enum Foo: string {
9292
case DEFAULT = 'default';
9393
/* testKeywordAsEnumCaseNameShouldBeString7 */
9494
case ARRAY = 'array';
95+
/* testKeywordAsEnumCaseNameShouldBeString8 */
96+
case EXIT = 'exit';
9597
}

tests/Core/Tokenizers/PHP/EnumCaseTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ public static function dataKeywordAsEnumCaseNameShouldBeString()
142142
'"false" as case name' => ['/* testKeywordAsEnumCaseNameShouldBeString5 */'],
143143
'"default" as case name' => ['/* testKeywordAsEnumCaseNameShouldBeString6 */'],
144144
'"array" as case name' => ['/* testKeywordAsEnumCaseNameShouldBeString7 */'],
145+
'"exit" as case name' => ['/* testKeywordAsEnumCaseNameShouldBeString8 */'],
145146
];
146147

147148
}//end dataKeywordAsEnumCaseNameShouldBeString()
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
/* testExitAsConstant */
4+
exit;
5+
6+
/* testDieAsConstant */
7+
die;
8+
9+
/* testExitAsConstantMixedCase */
10+
Exit;
11+
12+
/* testDieAsConstantUppercase */
13+
DIE;
14+
15+
/* testExitAsFunctionCallNoParam */
16+
exit();
17+
18+
/* testDieAsFunctionCallNoParam */
19+
die();
20+
21+
/* testExitAsFunctionCallWithParam */
22+
exit($exitcode);
23+
24+
/* testDieAsFunctionCallWithParam */
25+
die($status);
26+
27+
/* testExitAsFunctionCallUppercase */
28+
EXIT($exitcode);
29+
30+
/* testDieAsFunctionCallMixedCase */
31+
dIE($status);
32+
33+
/* testExitAsFQFunctionCallWithParam */
34+
\exit($exitcode);
35+
36+
/* testDieAsFQFunctionCallNoParam */
37+
\die($status);
38+
39+
40+
/* testNotExitOOConstantAccess */
41+
$obj::exit;
42+
43+
/* testNotDieOOConstantAccess */
44+
$obj::die;
45+
46+
/* testNotExitOOPropertyAccess */
47+
$obj->exit;
48+
49+
/* testNotDieOOPropertyAccess */
50+
$obj->DIE;
51+
52+
/* testNotExitOOMethodCall */
53+
$obj->exit();
54+
55+
/* testNotDieOOMethodCall */
56+
$obj->die();
57+
58+
class NotReserved {
59+
/* testNotExitOOConstDeclaration */
60+
const exit = 10;
61+
62+
/* testNotDieOOConstDeclaration */
63+
const die = 'status';
64+
65+
/* testNotExitOOMethodDeclaration */
66+
function Exit() {}
67+
68+
/* testNotDieOOMethodDeclaration */
69+
function die() {}
70+
}
71+
72+
/* testNotExitParamName */
73+
callMe(exit: 10);
74+
75+
/* testNotDieParamName */
76+
callMe(die: 'status');
77+
78+
/* testNotExitNamespacedName */
79+
use My\exit\NameA;
80+
81+
/* testNotDieNamespacedName */
82+
use My\die\NameB;
83+
84+
/* testExitAsFQConstant */
85+
// Intentional parse error. This is not allowed in PHP, but that's not the concern of the tokenizer. Should still be handled correctly.
86+
\exit;
87+
88+
/* testDieAsFQConstant */
89+
// Intentional parse error. This is not allowed in PHP, but that's not the concern of the tokenizer. Should still be handled correctly.
90+
\die;
91+
92+
/* testNotExitConstantDeclaration */
93+
// Intentional parse error. This is not allowed in PHP, but that's not the concern of the tokenizer. Should still be handled correctly.
94+
const exit = 10;
95+
96+
/* testNotDieConstantDeclaration */
97+
// Intentional parse error. This is not allowed in PHP, but that's not the concern of the tokenizer. Should still be handled correctly.
98+
const die = 'status';
99+
100+
/* testNotExitFunctionDeclaration */
101+
// Intentional parse error. This is not allowed in PHP, but that's not the concern of the tokenizer. Should still be handled correctly.
102+
function exit() {}
103+
104+
/* testNotDieFunctionDeclaration */
105+
// Intentional parse error. This is not allowed in PHP, but that's not the concern of the tokenizer. Should still be handled correctly.
106+
function die() {}

0 commit comments

Comments
 (0)