diff --git a/package.xml b/package.xml index d4c2aa4b3e..1dfb29dba5 100644 --- a/package.xml +++ b/package.xml @@ -126,6 +126,8 @@ http://pear.php.net/dtd/package-2.0.xsd"> + + @@ -2006,6 +2008,8 @@ http://pear.php.net/dtd/package-2.0.xsd"> + + @@ -2071,6 +2075,8 @@ http://pear.php.net/dtd/package-2.0.xsd"> + + diff --git a/src/Files/File.php b/src/Files/File.php index d1e9a4da87..eac30ef95c 100644 --- a/src/Files/File.php +++ b/src/Files/File.php @@ -1288,7 +1288,8 @@ public function getDeclarationName($stackPtr) * // or FALSE if there is no type hint. * 'type_hint_end_token' => integer, // The stack pointer to the end of the type hint * // or FALSE if there is no type hint. - * 'nullable_type' => boolean, // TRUE if the var type is nullable. + * 'nullable_type' => boolean, // TRUE if the type is preceded by the nullability + * // operator. * 'comma_token' => integer, // The stack pointer to the comma after the param * // or FALSE if this is the last param. * ) @@ -1443,6 +1444,9 @@ public function getMethodParameters($stackPtr) break; case T_NAMESPACE: case T_NS_SEPARATOR: + case T_TYPE_UNION: + case T_FALSE: + case T_NULL: // Part of a type hint or default value. if ($defaultStart === null) { if ($typeHintToken === false) { @@ -1533,7 +1537,8 @@ public function getMethodParameters($stackPtr) * 'return_type' => '', // The return type of the method. * 'return_type_token' => integer, // The stack pointer to the start of the return type * // or FALSE if there is no return type. - * 'nullable_return_type' => false, // TRUE if the return type is nullable. + * 'nullable_return_type' => false, // TRUE if the return type is preceded by the + * // nullability operator. * 'is_abstract' => false, // TRUE if the abstract keyword was found. * 'is_final' => false, // TRUE if the final keyword was found. * 'is_static' => false, // TRUE if the static keyword was found. @@ -1631,8 +1636,11 @@ public function getMethodProperties($stackPtr) T_SELF => T_SELF, T_PARENT => T_PARENT, T_STATIC => T_STATIC, + T_FALSE => T_FALSE, + T_NULL => T_NULL, T_NAMESPACE => T_NAMESPACE, T_NS_SEPARATOR => T_NS_SEPARATOR, + T_TYPE_UNION => T_TYPE_UNION, ]; for ($i = $this->tokens[$stackPtr]['parenthesis_closer']; $i < $this->numTokens; $i++) { @@ -1700,7 +1708,8 @@ public function getMethodProperties($stackPtr) * // or FALSE if there is no type. * 'type_end_token' => integer, // The stack pointer to the end of the type * // or FALSE if there is no type. - * 'nullable_type' => boolean, // TRUE if the type is nullable. + * 'nullable_type' => boolean, // TRUE if the type is preceded by the nullability + * // operator. * ); * * @@ -1815,8 +1824,11 @@ public function getMemberProperties($stackPtr) T_CALLABLE => T_CALLABLE, T_SELF => T_SELF, T_PARENT => T_PARENT, + T_FALSE => T_FALSE, + T_NULL => T_NULL, T_NAMESPACE => T_NAMESPACE, T_NS_SEPARATOR => T_NS_SEPARATOR, + T_TYPE_UNION => T_TYPE_UNION, ]; for ($i; $i < $stackPtr; $i++) { diff --git a/src/Tokenizers/PHP.php b/src/Tokenizers/PHP.php index 7aa484ef70..c0e202a759 100644 --- a/src/Tokenizers/PHP.php +++ b/src/Tokenizers/PHP.php @@ -439,6 +439,7 @@ class PHP extends Tokenizer T_BACKTICK => 1, T_OPEN_SHORT_ARRAY => 1, T_CLOSE_SHORT_ARRAY => 1, + T_TYPE_UNION => 1, ]; /** @@ -1475,6 +1476,7 @@ function return types. We want to keep the parenthesis map clean, T_SELF => T_SELF, T_PARENT => T_PARENT, T_NAMESPACE => T_NAMESPACE, + T_STATIC => T_STATIC, T_NS_SEPARATOR => T_NS_SEPARATOR, ]; @@ -1509,12 +1511,14 @@ function return types. We want to keep the parenthesis map clean, }//end for // Any T_ARRAY tokens we find between here and the next - // token that can't be part of the return type need to be + // token that can't be part of the return type, need to be // converted to T_STRING tokens. for ($x; $x < $numTokens; $x++) { - if (is_array($tokens[$x]) === false || isset($allowed[$tokens[$x][0]]) === false) { + if ((is_array($tokens[$x]) === false && $tokens[$x] !== '|') + || (is_array($tokens[$x]) === true && isset($allowed[$tokens[$x][0]]) === false) + ) { break; - } else if ($tokens[$x][0] === T_ARRAY) { + } else if (is_array($tokens[$x]) === true && $tokens[$x][0] === T_ARRAY) { $tokens[$x][0] = T_STRING; if (PHP_CODESNIFFER_VERBOSITY > 1) { @@ -1996,6 +2000,7 @@ protected function processAdditional() T_PARENT => T_PARENT, T_SELF => T_SELF, T_STATIC => T_STATIC, + T_TYPE_UNION => T_TYPE_UNION, ]; $closer = $this->tokens[$x]['parenthesis_closer']; @@ -2176,6 +2181,177 @@ protected function processAdditional() } } + continue; + } else if ($this->tokens[$i]['code'] === T_BITWISE_OR) { + /* + Convert "|" to T_TYPE_UNION or leave as T_BITWISE_OR. + */ + + $allowed = [ + T_STRING => T_STRING, + T_CALLABLE => T_CALLABLE, + T_SELF => T_SELF, + T_PARENT => T_PARENT, + T_STATIC => T_STATIC, + T_FALSE => T_FALSE, + T_NULL => T_NULL, + T_NS_SEPARATOR => T_NS_SEPARATOR, + ]; + + $suspectedType = null; + $typeTokenCount = 0; + + for ($x = ($i + 1); $x < $numTokens; $x++) { + if (isset(Util\Tokens::$emptyTokens[$this->tokens[$x]['code']]) === true) { + continue; + } + + if (isset($allowed[$this->tokens[$x]['code']]) === true) { + ++$typeTokenCount; + continue; + } + + if ($typeTokenCount > 0 + && ($this->tokens[$x]['code'] === T_BITWISE_AND + || $this->tokens[$x]['code'] === T_ELLIPSIS) + ) { + // Skip past reference and variadic indicators for parameter types. + ++$x; + continue; + } + + if ($this->tokens[$x]['code'] === T_VARIABLE) { + // Parameter/Property defaults can not contain variables, so this could be a type. + $suspectedType = 'property or parameter'; + break; + } + + if ($this->tokens[$x]['code'] === T_DOUBLE_ARROW) { + // Possible arrow function. + $suspectedType = 'return'; + break; + } + + if ($this->tokens[$x]['code'] === T_SEMICOLON) { + // Possible abstract method or interface method. + $suspectedType = 'return'; + break; + } + + if ($this->tokens[$x]['code'] === T_OPEN_CURLY_BRACKET + && isset($this->tokens[$x]['scope_condition']) === true + && $this->tokens[$this->tokens[$x]['scope_condition']]['code'] === T_FUNCTION + ) { + $suspectedType = 'return'; + } + + break; + }//end for + + if ($typeTokenCount === 0 || isset($suspectedType) === false) { + // Definitely not a union type, move on. + continue; + } + + $typeTokenCount = 0; + $unionOperators = [$i]; + $confirmed = false; + + for ($x = ($i - 1); $x >= 0; $x--) { + if (isset(Util\Tokens::$emptyTokens[$this->tokens[$x]['code']]) === true) { + continue; + } + + if (isset($allowed[$this->tokens[$x]['code']]) === true) { + ++$typeTokenCount; + continue; + } + + // Union types can't use the nullable operator, but be tolerant to parse errors. + if ($typeTokenCount > 0 && $this->tokens[$x]['code'] === T_NULLABLE) { + continue; + } + + if ($this->tokens[$x]['code'] === T_BITWISE_OR) { + $unionOperators[] = $x; + continue; + } + + if ($suspectedType === 'return' && $this->tokens[$x]['code'] === T_COLON) { + $confirmed = true; + break; + } + + if ($suspectedType === 'property or parameter' + && (isset(Util\Tokens::$scopeModifiers[$this->tokens[$x]['code']]) === true + || $this->tokens[$x]['code'] === T_VAR) + ) { + // This will also confirm constructor property promotion parameters, but that's fine. + $confirmed = true; + } + + break; + }//end for + + if ($confirmed === false + && $suspectedType === 'property or parameter' + && isset($this->tokens[$i]['nested_parenthesis']) === true + ) { + $parens = $this->tokens[$i]['nested_parenthesis']; + $last = end($parens); + + if (isset($this->tokens[$last]['parenthesis_owner']) === true + && $this->tokens[$this->tokens[$last]['parenthesis_owner']]['code'] === T_FUNCTION + ) { + $confirmed = true; + } else { + // No parenthesis owner set, this may be an arrow function which has not yet + // had additional processing done. + if (isset($this->tokens[$last]['parenthesis_opener']) === true) { + for ($x = ($this->tokens[$last]['parenthesis_opener'] - 1); $x >= 0; $x--) { + if (isset(Util\Tokens::$emptyTokens[$this->tokens[$x]['code']]) === true) { + continue; + } + + break; + } + + if ($this->tokens[$x]['code'] === T_FN) { + for (--$x; $x >= 0; $x--) { + if (isset(Util\Tokens::$emptyTokens[$this->tokens[$x]['code']]) === true + || $this->tokens[$x]['code'] === T_BITWISE_AND + ) { + continue; + } + + break; + } + + if ($this->tokens[$x]['code'] !== T_FUNCTION) { + $confirmed = true; + } + } + }//end if + }//end if + + unset($parens, $last); + }//end if + + if ($confirmed === false) { + // Not a union type after all, move on. + continue; + } + + foreach ($unionOperators as $x) { + $this->tokens[$x]['code'] = T_TYPE_UNION; + $this->tokens[$x]['type'] = 'T_TYPE_UNION'; + + if (PHP_CODESNIFFER_VERBOSITY > 1) { + $line = $this->tokens[$x]['line']; + echo "\t* token $x on line $line changed from T_BITWISE_OR to T_TYPE_UNION".PHP_EOL; + } + } + continue; } else if ($this->tokens[$i]['code'] === T_STATIC) { for ($x = ($i - 1); $x > 0; $x--) { diff --git a/src/Util/Tokens.php b/src/Util/Tokens.php index 952ccc2c12..aee035d352 100644 --- a/src/Util/Tokens.php +++ b/src/Util/Tokens.php @@ -75,6 +75,7 @@ define('T_ZSR', 'PHPCS_T_ZSR'); define('T_ZSR_EQUAL', 'PHPCS_T_ZSR_EQUAL'); define('T_FN_ARROW', 'T_FN_ARROW'); +define('T_TYPE_UNION', 'T_TYPE_UNION'); // Some PHP 5.5 tokens, replicated for lower versions. if (defined('T_FINALLY') === false) { diff --git a/tests/Core/File/GetMemberPropertiesTest.inc b/tests/Core/File/GetMemberPropertiesTest.inc index 0a511e588d..9cc56bd53a 100644 --- a/tests/Core/File/GetMemberPropertiesTest.inc +++ b/tests/Core/File/GetMemberPropertiesTest.inc @@ -193,3 +193,50 @@ class NSOperatorInType { /* testNamespaceOperatorTypeHint */ public ?namespace\Name $prop; } + +$anon = class() { + /* testPHP8UnionTypesSimple */ + public int|float $unionTypeSimple; + + /* testPHP8UnionTypesTwoClasses */ + private MyClassA|\Package\MyClassB $unionTypesTwoClasses; + + /* testPHP8UnionTypesAllBaseTypes */ + protected array|bool|int|float|NULL|object|string $unionTypesAllBaseTypes; + + /* testPHP8UnionTypesAllPseudoTypes */ + // Intentional fatal error - mixing types which cannot be combined, but that's not the concern of the method. + var false|mixed|self|parent|iterable|Resource $unionTypesAllPseudoTypes; + + /* testPHP8UnionTypesIllegalTypes */ + // Intentional fatal error - types which are not allowed for properties, but that's not the concern of the method. + public callable|static|void $unionTypesIllegalTypes; + + /* testPHP8UnionTypesNullable */ + // Intentional fatal error - nullability is not allowed with union types, but that's not the concern of the method. + public ?int|float $unionTypesNullable; + + /* testPHP8PseudoTypeNull */ + // Intentional fatal error - null pseudotype is only allowed in union types, but that's not the concern of the method. + public null $pseudoTypeNull; + + /* testPHP8PseudoTypeFalse */ + // Intentional fatal error - false pseudotype is only allowed in union types, but that's not the concern of the method. + public false $pseudoTypeFalse; + + /* testPHP8PseudoTypeFalseAndBool */ + // Intentional fatal error - false pseudotype is not allowed in combination with bool, but that's not the concern of the method. + public bool|FALSE $pseudoTypeFalseAndBool; + + /* testPHP8ObjectAndClass */ + // Intentional fatal error - object is not allowed in combination with class name, but that's not the concern of the method. + public object|ClassName $objectAndClass; + + /* testPHP8PseudoTypeIterableAndArray */ + // Intentional fatal error - iterable pseudotype is not allowed in combination with array or Traversable, but that's not the concern of the method. + public iterable|array|Traversable $pseudoTypeIterableAndArray; + + /* testPHP8DuplicateTypeInUnionWhitespaceAndComment */ + // Intentional fatal error - duplicate types are not allowed in union types, but that's not the concern of the method. + public int |string| /*comment*/ INT $duplicateTypeInUnion; +}; diff --git a/tests/Core/File/GetMemberPropertiesTest.php b/tests/Core/File/GetMemberPropertiesTest.php index dfee43e203..a59effc752 100644 --- a/tests/Core/File/GetMemberPropertiesTest.php +++ b/tests/Core/File/GetMemberPropertiesTest.php @@ -489,6 +489,127 @@ public function dataGetMemberProperties() 'nullable_type' => true, ], ], + [ + '/* testPHP8UnionTypesSimple */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'int|float', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8UnionTypesTwoClasses */', + [ + 'scope' => 'private', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'MyClassA|\Package\MyClassB', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8UnionTypesAllBaseTypes */', + [ + 'scope' => 'protected', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'array|bool|int|float|NULL|object|string', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8UnionTypesAllPseudoTypes */', + [ + 'scope' => 'public', + 'scope_specified' => false, + 'is_static' => false, + 'type' => 'false|mixed|self|parent|iterable|Resource', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8UnionTypesIllegalTypes */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + // Missing static, but that's OK as not an allowed syntax. + 'type' => 'callable||void', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8UnionTypesNullable */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + 'type' => '?int|float', + 'nullable_type' => true, + ], + ], + [ + '/* testPHP8PseudoTypeNull */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'null', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8PseudoTypeFalse */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'false', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8PseudoTypeFalseAndBool */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'bool|FALSE', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8ObjectAndClass */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'object|ClassName', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8PseudoTypeIterableAndArray */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'iterable|array|Traversable', + 'nullable_type' => false, + ], + ], + [ + '/* testPHP8DuplicateTypeInUnionWhitespaceAndComment */', + [ + 'scope' => 'public', + 'scope_specified' => true, + 'is_static' => false, + 'type' => 'int|string|INT', + 'nullable_type' => false, + ], + ], ]; }//end dataGetMemberProperties() diff --git a/tests/Core/File/GetMethodParametersTest.inc b/tests/Core/File/GetMethodParametersTest.inc index 4ffd44221f..56fa6eae41 100644 --- a/tests/Core/File/GetMethodParametersTest.inc +++ b/tests/Core/File/GetMethodParametersTest.inc @@ -41,3 +41,47 @@ function mixedTypeHintNullable(?Mixed $var1) {} /* testNamespaceOperatorTypeHint */ function namespaceOperatorTypeHint(?namespace\Name $var1) {} + +/* testPHP8UnionTypesSimple */ +function unionTypeSimple(int|float $number, self|parent &...$obj) {} + +/* testPHP8UnionTypesSimpleWithBitwiseOrInDefault */ +$fn = fn(int|float $var = CONSTANT_A | CONSTANT_B) => $var; + +/* testPHP8UnionTypesTwoClasses */ +function unionTypesTwoClasses(MyClassA|\Package\MyClassB $var) {} + +/* testPHP8UnionTypesAllBaseTypes */ +function unionTypesAllBaseTypes(array|bool|callable|int|float|null|object|string $var) {} + +/* testPHP8UnionTypesAllPseudoTypes */ +// Intentional fatal error - mixing types which cannot be combined, but that's not the concern of the method. +function unionTypesAllPseudoTypes(false|mixed|self|parent|iterable|Resource $var) {} + +/* testPHP8UnionTypesNullable */ +// Intentional fatal error - nullability is not allowed with union types, but that's not the concern of the method. +$closure = function (?int|float $number) {}; + +/* testPHP8PseudoTypeNull */ +// Intentional fatal error - null pseudotype is only allowed in union types, but that's not the concern of the method. +function pseudoTypeNull(null $var = null) {} + +/* testPHP8PseudoTypeFalse */ +// Intentional fatal error - false pseudotype is only allowed in union types, but that's not the concern of the method. +function pseudoTypeFalse(false $var = false) {} + +/* testPHP8PseudoTypeFalseAndBool */ +// Intentional fatal error - false pseudotype is not allowed in combination with bool, but that's not the concern of the method. +function pseudoTypeFalseAndBool(bool|false $var = false) {} + +/* testPHP8ObjectAndClass */ +// Intentional fatal error - object is not allowed in combination with class name, but that's not the concern of the method. +function objectAndClass(object|ClassName $var) {} + +/* testPHP8PseudoTypeIterableAndArray */ +// Intentional fatal error - iterable pseudotype is not allowed in combination with array or Traversable, but that's not the concern of the method. +function pseudoTypeIterableAndArray(iterable|array|Traversable $var) {} + +/* testPHP8DuplicateTypeInUnionWhitespaceAndComment */ +// Intentional fatal error - duplicate types are not allowed in union types, but that's not the concern of the method. +function duplicateTypeInUnion( int | string /*comment*/ | INT $var) {} diff --git a/tests/Core/File/GetMethodParametersTest.php b/tests/Core/File/GetMethodParametersTest.php index 92f51959e6..ad4cf8b67e 100644 --- a/tests/Core/File/GetMethodParametersTest.php +++ b/tests/Core/File/GetMethodParametersTest.php @@ -340,6 +340,282 @@ public function testNamespaceOperatorTypeHint() }//end testNamespaceOperatorTypeHint() + /** + * Verify recognition of PHP8 union type declaration. + * + * @return void + */ + public function testPHP8UnionTypesSimple() + { + $expected = []; + $expected[0] = [ + 'name' => '$number', + 'content' => 'int|float $number', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'int|float', + 'nullable_type' => false, + ]; + $expected[1] = [ + 'name' => '$obj', + 'content' => 'self|parent &...$obj', + 'pass_by_reference' => true, + 'variable_length' => true, + 'type_hint' => 'self|parent', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesSimple() + + + /** + * Verify recognition of PHP8 union type declaration with a bitwise or in the default value. + * + * @return void + */ + public function testPHP8UnionTypesSimpleWithBitwiseOrInDefault() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'int|float $var = CONSTANT_A | CONSTANT_B', + 'default' => 'CONSTANT_A | CONSTANT_B', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'int|float', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesSimpleWithBitwiseOrInDefault() + + + /** + * Verify recognition of PHP8 union type declaration with two classes. + * + * @return void + */ + public function testPHP8UnionTypesTwoClasses() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'MyClassA|\Package\MyClassB $var', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'MyClassA|\Package\MyClassB', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesTwoClasses() + + + /** + * Verify recognition of PHP8 union type declaration with all base types. + * + * @return void + */ + public function testPHP8UnionTypesAllBaseTypes() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'array|bool|callable|int|float|null|object|string $var', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'array|bool|callable|int|float|null|object|string', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesAllBaseTypes() + + + /** + * Verify recognition of PHP8 union type declaration with all pseudo types. + * + * @return void + */ + public function testPHP8UnionTypesAllPseudoTypes() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'false|mixed|self|parent|iterable|Resource $var', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'false|mixed|self|parent|iterable|Resource', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesAllPseudoTypes() + + + /** + * Verify recognition of PHP8 union type declaration with (illegal) nullability. + * + * @return void + */ + public function testPHP8UnionTypesNullable() + { + $expected = []; + $expected[0] = [ + 'name' => '$number', + 'content' => '?int|float $number', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => '?int|float', + 'nullable_type' => true, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesNullable() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) single type null. + * + * @return void + */ + public function testPHP8PseudoTypeNull() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'null $var = null', + 'default' => 'null', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'null', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8PseudoTypeNull() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) single type false. + * + * @return void + */ + public function testPHP8PseudoTypeFalse() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'false $var = false', + 'default' => 'false', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'false', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8PseudoTypeFalse() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) type false combined with type bool. + * + * @return void + */ + public function testPHP8PseudoTypeFalseAndBool() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'bool|false $var = false', + 'default' => 'false', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'bool|false', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8PseudoTypeFalseAndBool() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) type object combined with a class name. + * + * @return void + */ + public function testPHP8ObjectAndClass() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'object|ClassName $var', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'object|ClassName', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8ObjectAndClass() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) type iterable combined with array/Traversable. + * + * @return void + */ + public function testPHP8PseudoTypeIterableAndArray() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'iterable|array|Traversable $var', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'iterable|array|Traversable', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8PseudoTypeIterableAndArray() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) duplicate types. + * + * @return void + */ + public function testPHP8DuplicateTypeInUnionWhitespaceAndComment() + { + $expected = []; + $expected[0] = [ + 'name' => '$var', + 'content' => 'int | string /*comment*/ | INT $var', + 'pass_by_reference' => false, + 'variable_length' => false, + 'type_hint' => 'int|string|INT', + 'nullable_type' => false, + ]; + + $this->getMethodParametersTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8DuplicateTypeInUnionWhitespaceAndComment() + + /** * Test helper. * @@ -350,7 +626,7 @@ public function testNamespaceOperatorTypeHint() */ private function getMethodParametersTestHelper($commentString, $expected) { - $function = $this->getTargetToken($commentString, [T_FUNCTION, T_FN]); + $function = $this->getTargetToken($commentString, [T_FUNCTION, T_CLOSURE, T_FN]); $found = self::$phpcsFile->getMethodParameters($function); $this->assertArraySubset($expected, $found, true); diff --git a/tests/Core/File/GetMethodPropertiesTest.inc b/tests/Core/File/GetMethodPropertiesTest.inc index 82c032efa0..3e9682df6e 100644 --- a/tests/Core/File/GetMethodPropertiesTest.inc +++ b/tests/Core/File/GetMethodPropertiesTest.inc @@ -83,3 +83,46 @@ function mixedTypeHintNullable(): ?mixed {} /* testNamespaceOperatorTypeHint */ function namespaceOperatorTypeHint() : ?namespace\Name {} + +/* testPHP8UnionTypesSimple */ +function unionTypeSimple($number) : int|float {} + +/* testPHP8UnionTypesTwoClasses */ +$fn = fn($var): MyClassA|\Package\MyClassB => $var; + +/* testPHP8UnionTypesAllBaseTypes */ +function unionTypesAllBaseTypes() : array|bool|callable|int|float|null|Object|string {} + +/* testPHP8UnionTypesAllPseudoTypes */ +// Intentional fatal error - mixing types which cannot be combined, but that's not the concern of the method. +function unionTypesAllPseudoTypes($var) : false|MIXED|self|parent|static|iterable|Resource|void {} + +/* testPHP8UnionTypesNullable */ +// Intentional fatal error - nullability is not allowed with union types, but that's not the concern of the method. +$closure = function () use($a) :?int|float {}; + +/* testPHP8PseudoTypeNull */ +// Intentional fatal error - null pseudotype is only allowed in union types, but that's not the concern of the method. +function pseudoTypeNull(): null {} + +/* testPHP8PseudoTypeFalse */ +// Intentional fatal error - false pseudotype is only allowed in union types, but that's not the concern of the method. +function pseudoTypeFalse(): false {} + +/* testPHP8PseudoTypeFalseAndBool */ +// Intentional fatal error - false pseudotype is not allowed in combination with bool, but that's not the concern of the method. +function pseudoTypeFalseAndBool(): bool|false {} + +/* testPHP8ObjectAndClass */ +// Intentional fatal error - object is not allowed in combination with class name, but that's not the concern of the method. +function objectAndClass(): object|ClassName {} + +/* testPHP8PseudoTypeIterableAndArray */ +// Intentional fatal error - iterable pseudotype is not allowed in combination with array or Traversable, but that's not the concern of the method. +interface FooBar { + public function pseudoTypeIterableAndArray(): iterable|array|Traversable; +} + +/* testPHP8DuplicateTypeInUnionWhitespaceAndComment */ +// Intentional fatal error - duplicate types are not allowed in union types, but that's not the concern of the method. +function duplicateTypeInUnion(): int | /*comment*/ string | INT {} diff --git a/tests/Core/File/GetMethodPropertiesTest.php b/tests/Core/File/GetMethodPropertiesTest.php index ee11fc2b9f..d912d6b1ce 100644 --- a/tests/Core/File/GetMethodPropertiesTest.php +++ b/tests/Core/File/GetMethodPropertiesTest.php @@ -475,6 +475,259 @@ public function testNamespaceOperatorTypeHint() }//end testNamespaceOperatorTypeHint() + /** + * Verify recognition of PHP8 union type declaration. + * + * @return void + */ + public function testPHP8UnionTypesSimple() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'int|float', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesSimple() + + + /** + * Verify recognition of PHP8 union type declaration with two classes. + * + * @return void + */ + public function testPHP8UnionTypesTwoClasses() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'MyClassA|\Package\MyClassB', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesTwoClasses() + + + /** + * Verify recognition of PHP8 union type declaration with all base types. + * + * @return void + */ + public function testPHP8UnionTypesAllBaseTypes() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'array|bool|callable|int|float|null|Object|string', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesAllBaseTypes() + + + /** + * Verify recognition of PHP8 union type declaration with all pseudo types. + * + * @return void + */ + public function testPHP8UnionTypesAllPseudoTypes() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'false|MIXED|self|parent|static|iterable|Resource|void', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesAllPseudoTypes() + + + /** + * Verify recognition of PHP8 union type declaration with (illegal) nullability. + * + * @return void + */ + public function testPHP8UnionTypesNullable() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => '?int|float', + 'nullable_return_type' => true, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8UnionTypesNullable() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) single type null. + * + * @return void + */ + public function testPHP8PseudoTypeNull() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'null', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8PseudoTypeNull() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) single type false. + * + * @return void + */ + public function testPHP8PseudoTypeFalse() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'false', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8PseudoTypeFalse() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) type false combined with type bool. + * + * @return void + */ + public function testPHP8PseudoTypeFalseAndBool() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'bool|false', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8PseudoTypeFalseAndBool() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) type object combined with a class name. + * + * @return void + */ + public function testPHP8ObjectAndClass() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'object|ClassName', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8ObjectAndClass() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) type iterable combined with array/Traversable. + * + * @return void + */ + public function testPHP8PseudoTypeIterableAndArray() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => true, + 'return_type' => 'iterable|array|Traversable', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => false, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8PseudoTypeIterableAndArray() + + + /** + * Verify recognition of PHP8 type declaration with (illegal) duplicate types. + * + * @return void + */ + public function testPHP8DuplicateTypeInUnionWhitespaceAndComment() + { + $expected = [ + 'scope' => 'public', + 'scope_specified' => false, + 'return_type' => 'int|string|INT', + 'nullable_return_type' => false, + 'is_abstract' => false, + 'is_final' => false, + 'is_static' => false, + 'has_body' => true, + ]; + + $this->getMethodPropertiesTestHelper('/* '.__FUNCTION__.' */', $expected); + + }//end testPHP8DuplicateTypeInUnionWhitespaceAndComment() + + /** * Test helper. * diff --git a/tests/Core/Tokenizer/BackfillFnTokenTest.inc b/tests/Core/Tokenizer/BackfillFnTokenTest.inc index 46c165a2d2..f470bb0c67 100644 --- a/tests/Core/Tokenizer/BackfillFnTokenTest.inc +++ b/tests/Core/Tokenizer/BackfillFnTokenTest.inc @@ -81,6 +81,12 @@ fn(array $a) : array => $a; /* testStaticReturnType */ fn(array $a) : static => $a; +/* testUnionParamType */ +$arrowWithUnionParam = fn(int|float $param) : SomeClass => new SomeClass($param); + +/* testUnionReturnType */ +$arrowWithUnionReturn = fn($param) : int|float => $param | 10; + /* testTernary */ $fn = fn($a) => $a ? /* testTernaryThen */ fn() : string => 'a' : /* testTernaryElse */ fn() : string => 'b'; @@ -130,6 +136,9 @@ $a = MyNS\Sub\Fn($param); /* testNonArrowNamespaceOperatorFunctionCall */ $a = namespace\fn($param); +/* testNonArrowFunctionNameWithUnionTypes */ +function fn(int|float $param) : string|null {} + /* testLiveCoding */ // Intentional parse error. This has to be the last test in the file. $fn = fn diff --git a/tests/Core/Tokenizer/BackfillFnTokenTest.php b/tests/Core/Tokenizer/BackfillFnTokenTest.php index 9ef8dad442..b8ea0170ee 100644 --- a/tests/Core/Tokenizer/BackfillFnTokenTest.php +++ b/tests/Core/Tokenizer/BackfillFnTokenTest.php @@ -531,6 +531,62 @@ public function testKeywordReturnTypes() }//end testKeywordReturnTypes() + /** + * Test arrow function with a union parameter type. + * + * @covers PHP_CodeSniffer\Tokenizers\PHP::processAdditional + * + * @return void + */ + public function testUnionParamType() + { + $tokens = self::$phpcsFile->getTokens(); + + $token = $this->getTargetToken('/* testUnionParamType */', T_FN); + $this->backfillHelper($token); + + $this->assertSame($tokens[$token]['scope_opener'], ($token + 13), 'Scope opener is not the arrow token'); + $this->assertSame($tokens[$token]['scope_closer'], ($token + 21), 'Scope closer is not the semicolon token'); + + $opener = $tokens[$token]['scope_opener']; + $this->assertSame($tokens[$opener]['scope_opener'], ($token + 13), 'Opener scope opener is not the arrow token'); + $this->assertSame($tokens[$opener]['scope_closer'], ($token + 21), 'Opener scope closer is not the semicolon token'); + + $closer = $tokens[$token]['scope_closer']; + $this->assertSame($tokens[$closer]['scope_opener'], ($token + 13), 'Closer scope opener is not the arrow token'); + $this->assertSame($tokens[$closer]['scope_closer'], ($token + 21), 'Closer scope closer is not the semicolon token'); + + }//end testUnionParamType() + + + /** + * Test arrow function with a union return type. + * + * @covers PHP_CodeSniffer\Tokenizers\PHP::processAdditional + * + * @return void + */ + public function testUnionReturnType() + { + $tokens = self::$phpcsFile->getTokens(); + + $token = $this->getTargetToken('/* testUnionReturnType */', T_FN); + $this->backfillHelper($token); + + $this->assertSame($tokens[$token]['scope_opener'], ($token + 11), 'Scope opener is not the arrow token'); + $this->assertSame($tokens[$token]['scope_closer'], ($token + 18), 'Scope closer is not the semicolon token'); + + $opener = $tokens[$token]['scope_opener']; + $this->assertSame($tokens[$opener]['scope_opener'], ($token + 11), 'Opener scope opener is not the arrow token'); + $this->assertSame($tokens[$opener]['scope_closer'], ($token + 18), 'Opener scope closer is not the semicolon token'); + + $closer = $tokens[$token]['scope_closer']; + $this->assertSame($tokens[$closer]['scope_opener'], ($token + 11), 'Closer scope opener is not the arrow token'); + $this->assertSame($tokens[$closer]['scope_closer'], ($token + 18), 'Closer scope closer is not the semicolon token'); + + }//end testUnionReturnType() + + /** * Test arrow functions used in ternary operators. * @@ -690,6 +746,7 @@ public function dataNotAnArrowFunction() 'Fn', ], ['/* testNonArrowNamespaceOperatorFunctionCall */'], + ['/* testNonArrowFunctionNameWithUnionTypes */'], ['/* testLiveCoding */'], ]; diff --git a/tests/Core/Tokenizer/BitwiseOrTest.inc b/tests/Core/Tokenizer/BitwiseOrTest.inc new file mode 100644 index 0000000000..921ef7f5ae --- /dev/null +++ b/tests/Core/Tokenizer/BitwiseOrTest.inc @@ -0,0 +1,85 @@ + $param | $int; + +/* testTypeUnionArrowReturnType */ +$arrowWithReturnType = fn ($param) : int|null => $param * 10; + +/* testBitwiseOrInArrayKey */ +$array = array( + A | B => /* testBitwiseOrInArrayValue */ B | C +); + +/* testBitwiseOrInShortArrayKey */ +$array = [ + A | B => /* testBitwiseOrInShortArrayValue */ B | C +]; + +/* testBitwiseOrTryCatch */ +try { +} catch ( ExceptionA | ExceptionB $e ) { +} + +/* testBitwiseOrNonArrowFnFunctionCall */ +$obj->fn($something | $else); + +/* testTypeUnionNonArrowFunctionDeclaration */ +function &fn(int|false $something) {} + +/* testLiveCoding */ +// Intentional parse error. This has to be the last test in the file. +return function( type| diff --git a/tests/Core/Tokenizer/BitwiseOrTest.php b/tests/Core/Tokenizer/BitwiseOrTest.php new file mode 100644 index 0000000000..88d8e587a0 --- /dev/null +++ b/tests/Core/Tokenizer/BitwiseOrTest.php @@ -0,0 +1,122 @@ + + * @copyright 2020 Squiz Pty Ltd (ABN 77 084 670 600) + * @license https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + */ + +namespace PHP_CodeSniffer\Tests\Core\Tokenizer; + +use PHP_CodeSniffer\Tests\Core\AbstractMethodUnitTest; + +class BitwiseOrTest extends AbstractMethodUnitTest +{ + + + /** + * Test that non-union type bitwise or tokens are still tokenized as bitwise or. + * + * @param string $testMarker The comment which prefaces the target token in the test file. + * + * @dataProvider dataBitwiseOr + * @covers PHP_CodeSniffer\Tokenizers\PHP::processAdditional + * + * @return void + */ + public function testBitwiseOr($testMarker) + { + $tokens = self::$phpcsFile->getTokens(); + + $opener = $this->getTargetToken($testMarker, [T_BITWISE_OR, T_TYPE_UNION]); + $this->assertSame(T_BITWISE_OR, $tokens[$opener]['code']); + $this->assertSame('T_BITWISE_OR', $tokens[$opener]['type']); + + }//end testBitwiseOr() + + + /** + * Data provider. + * + * @see testBitwiseOr() + * + * @return array + */ + public function dataBitwiseOr() + { + return [ + ['/* testBitwiseOr1 */'], + ['/* testBitwiseOr2 */'], + ['/* testBitwiseOrPropertyDefaultValue */'], + ['/* testBitwiseOrParamDefaultValue */'], + ['/* testBitwiseOr3 */'], + ['/* testBitwiseOrClosureParamDefault */'], + ['/* testBitwiseOrArrowParamDefault */'], + ['/* testBitwiseOrArrowExpression */'], + ['/* testBitwiseOrInArrayKey */'], + ['/* testBitwiseOrInArrayValue */'], + ['/* testBitwiseOrInShortArrayKey */'], + ['/* testBitwiseOrInShortArrayValue */'], + ['/* testBitwiseOrTryCatch */'], + ['/* testBitwiseOrNonArrowFnFunctionCall */'], + ['/* testLiveCoding */'], + ]; + + }//end dataBitwiseOr() + + + /** + * Test that bitwise or tokens when used as part of a union type are tokenized as `T_TYPE_UNION`. + * + * @param string $testMarker The comment which prefaces the target token in the test file. + * + * @dataProvider dataTypeUnion + * @covers PHP_CodeSniffer\Tokenizers\PHP::processAdditional + * + * @return void + */ + public function testTypeUnion($testMarker) + { + $tokens = self::$phpcsFile->getTokens(); + + $opener = $this->getTargetToken($testMarker, [T_BITWISE_OR, T_TYPE_UNION]); + $this->assertSame(T_TYPE_UNION, $tokens[$opener]['code']); + $this->assertSame('T_TYPE_UNION', $tokens[$opener]['type']); + + }//end testTypeUnion() + + + /** + * Data provider. + * + * @see testTypeUnion() + * + * @return array + */ + public function dataTypeUnion() + { + return [ + ['/* testTypeUnionPropertySimple */'], + ['/* testTypeUnionPropertyReverseModifierOrder */'], + ['/* testTypeUnionPropertyMulti1 */'], + ['/* testTypeUnionPropertyMulti2 */'], + ['/* testTypeUnionPropertyMulti3 */'], + ['/* testTypeUnionParam1 */'], + ['/* testTypeUnionParam2 */'], + ['/* testTypeUnionParam3 */'], + ['/* testTypeUnionReturnType */'], + ['/* testTypeUnionConstructorPropertyPromotion */'], + ['/* testTypeUnionAbstractMethodReturnType1 */'], + ['/* testTypeUnionAbstractMethodReturnType2 */'], + ['/* testTypeUnionClosureParamIllegalNullable */'], + ['/* testTypeUnionClosureReturn */'], + ['/* testTypeUnionArrowParam */'], + ['/* testTypeUnionArrowReturnType */'], + ['/* testTypeUnionNonArrowFunctionDeclaration */'], + ]; + + }//end dataTypeUnion() + + +}//end class diff --git a/tests/Core/Tokenizer/UndoNamespacedNameSingleTokenTest.php b/tests/Core/Tokenizer/UndoNamespacedNameSingleTokenTest.php index 27cd47783e..24667e486f 100644 --- a/tests/Core/Tokenizer/UndoNamespacedNameSingleTokenTest.php +++ b/tests/Core/Tokenizer/UndoNamespacedNameSingleTokenTest.php @@ -657,9 +657,8 @@ public function dataIdentifierTokenization() 'type' => 'T_STRING', 'content' => 'Name', ], - // TODO: change this to T_TYPE_UNION when #3032 is merged. [ - 'type' => 'T_BITWISE_OR', + 'type' => 'T_TYPE_UNION', 'content' => '|', ], [ @@ -708,9 +707,8 @@ public function dataIdentifierTokenization() 'type' => 'T_STRING', 'content' => 'Unqualified', ], - // TODO: change this to T_TYPE_UNION when #3032 is merged. [ - 'type' => 'T_BITWISE_OR', + 'type' => 'T_TYPE_UNION', 'content' => '|', ], [