diff --git a/PHPCSUtils/Utils/TypeString.php b/PHPCSUtils/Utils/TypeString.php new file mode 100644 index 00000000..79bd01f6 --- /dev/null +++ b/PHPCSUtils/Utils/TypeString.php @@ -0,0 +1,393 @@ + + */ + private static $keywordTypes = [ + 'array' => 'array', + 'bool' => 'bool', + 'callable' => 'callable', + 'false' => 'false', + 'float' => 'float', + 'int' => 'int', + 'iterable' => 'iterable', + 'mixed' => 'mixed', + 'never' => 'never', + 'null' => 'null', + 'object' => 'object', + 'parent' => 'parent', + 'self' => 'self', + 'static' => 'static', + 'string' => 'string', + 'true' => 'true', + 'void' => 'void', + ]; + + /** + * Retrieve a list of all PHP native keyword types. + * + * @since 1.1.0 + * + * @return array Key and value both contain the type name in lowercase. + */ + public static function getKeywordTypes() + { + return self::$keywordTypes; + } + + /** + * Check if a singular type is a PHP native keyword based type. + * + * @since 1.1.0 + * + * @param string $type The singular type. + * + * @return bool + */ + public static function isKeyword($type) + { + if (\is_string($type) === false) { + return false; + } + + $typeLC = \strtolower(\trim($type)); + return isset(self::$keywordTypes[$typeLC]); + } + + /** + * Normalize the case for a single type. + * + * - Types which are recognized PHP "keyword" types will be returned in lowercase. + * - Class/Interface/Enum names will be returned in their original case. + * + * @since 1.1.0 + * + * @param string $type Type to normalize the case for. + * + * @return string The case-normalized type or an empty string if the input was invalid. + */ + public static function normalizeCase($type) + { + if (\is_string($type) === false) { + return ''; + } + + if (self::isKeyword($type)) { + return \strtolower($type); + } + + return $type; + } + + /** + * Check if a type string represents a plain, singular type. + * + * Note: Nullable types are not considered plain, singular types for the purposes of this method. + * + * @since 1.1.0 + * + * @param string $typeString Type string. + * + * @return bool + */ + public static function isSingular($typeString) + { + if (\is_string($typeString) === false) { + return false; + } + + $typeString = \trim($typeString); + + return empty($typeString) === false + && \strpos($typeString, '?') === false + && \strpos($typeString, '|') === false + && \strpos($typeString, '&') === false + && \strpos($typeString, '(') === false + && \strpos($typeString, ')') === false; + } + + /** + * Check if a type string represents a nullable type. + * + * A nullable type in the context of this method is a type which + * - starts with the nullable operator and has something after it which is being made nullable; + * - or contains `null` as part of a union or DNF type. + * + * A stand-alone `null` type is not considered a nullable type, but a singular type. + * + * @since 1.1.0 + * + * @param string $typeString Type string. + * + * @return bool + */ + public static function isNullable($typeString) + { + if (\is_string($typeString) === false) { + return false; + } + + $typeString = \trim($typeString); + if (empty($typeString) === true) { + return false; + } + + // Check for plain nullable type with something which is being made nullable. + if (\preg_match('`^\?\s*[^|&()?\s]+`', $typeString) === 1) { + return true; + } + + // Check for nullable union type. + $matched = \preg_match( + '`(?^|[^|&(?\s]+\s*\|)\s*null\s*(?\|\s*[^|&)?\s]+|$)`i', + $typeString, + $matches + ); + return ($matched === 1 + && (empty($matches['before']) === false || empty($matches['after']) === false)); + } + + /** + * Check if a type string represents a pure union type. + * + * Note: DNF types are not considered union types for the purpose of this method. + * + * @since 1.1.0 + * + * @param string $typeString Type string. + * + * @return bool + */ + public static function isUnion($typeString) + { + return \is_string($typeString) + && \strpos($typeString, '?') === false + && \strpos($typeString, '|') !== false + && \strpos($typeString, '&') === false + && \strpos($typeString, '(') === false + && \strpos($typeString, ')') === false + // Make sure there is always something before and after each |. + && \preg_match('`^[^|&()?\s]+(\s*\|\s*[^|&()?\s]+)+$`', $typeString) === 1; + } + + /** + * Check if a type string represents a pure intersection type. + * + * Note: DNF types are not considered intersection types for the purpose of this method. + * + * @since 1.1.0 + * + * @param string $typeString Type string. + * + * @return bool + */ + public static function isIntersection($typeString) + { + return \is_string($typeString) + && \strpos($typeString, '?') === false + && \strpos($typeString, '|') === false + && \strpos($typeString, '&') !== false + && \strpos($typeString, '(') === false + && \strpos($typeString, ')') === false + // Make sure there is always something before and after each &. + && \preg_match('`^[^|&()?\s]+(\s*&\s*[^|&()?\s]+)+$`', $typeString) === 1; + } + + /** + * Check if a type string represents a disjunctive normal form (DNF) type. + * + * This check for a strict + * + * @since 1.1.0 + * + * @param string $typeString Type string. + * + * @return bool + */ + public static function isDNF($typeString) + { + return \is_string($typeString) + && \strpos($typeString, '?') === false + && \strpos($typeString, '|') !== false + && \strpos($typeString, '&') !== false + && \strpos($typeString, '(') !== false + && \strpos($typeString, ')') !== false + // Now make sure that it is not a definitely invalid format. + && \preg_match(self::INVALID_DNF_REGEX, $typeString) !== 1; + } + + /** + * Split a type string to its individual types and optionally normalize the case of the types. + * + * @since 1.1.0 + * + * @param string $typeString Type to split. + * @param bool $normalize Whether or not to normalize the case of types. + * Defaults to true. + * + * @return array List containing all seen types in the order they were encountered. + */ + public static function toArray($typeString, $normalize = true) + { + if (\is_string($typeString) === false || \trim($typeString) === '') { + return []; + } + + $addNull = false; + if ($typeString[0] === '?') { + $addNull = true; + $typeString = \substr($typeString, 1); + } + + $typeString = \preg_replace('`\s+`', '', $typeString); + $types = \preg_split('`[|&()]+`', $typeString, -1, \PREG_SPLIT_NO_EMPTY); + + // Normalize the types. + if ($normalize === true) { + $types = \array_map([__CLASS__, 'normalizeCase'], $types); + } + + if ($addNull === true) { + \array_unshift($types, 'null'); + } + + return $types; + } + + /** + * Split a type string to the unique types included and optionally normalize the case of the types. + * + * @since 1.1.0 + * + * @param string $typeString Type to split. + * @param bool $normalize Whether or not to normalize the case of types. + * Defaults to true. + * + * @return array Associative array with the unique types as both the key as well as the value. + */ + public static function toArrayUnique($typeString, $normalize = true) + { + $types = self::toArray($typeString, $normalize); + return \array_combine($types, $types); + } + + /** + * Filter a list of types down to only the keyword based types. + * + * @since 1.1.0 + * + * @param array $types Array of types. + * Typically, this is an array as retrieved from the + * {@see TypeString::toArray()} method or the + * {@see TypeString::toArrayUnique()} method. + * + * @return array Array with only the PHP native keyword based types. + * The result may be an empty array if the input array didn't contain + * any keyword based types or if the input was invalid. + */ + public static function filterKeywordTypes(array $types) + { + return \array_filter($types, [__CLASS__, 'isKeyword']); + } + + /** + * Filter a list of types down to only the OO name based types. + * + * @since 1.1.0 + * + * @param array $types Array of types. + * Typically, this is an array as retrieved from the + * {@see TypeString::toArray()} method or the + * {@see TypeString::toArrayUnique()} method. + * + * @return array Array with only the OO name based types. + * The result may be an empty array if the input array didn't contain + * any OO name based types or if the input was invalid. + */ + public static function filterOOTypes(array $types) + { + return \array_filter( + $types, + static function ($type) { + return \is_string($type) === true && self::isKeyword($type) === false; + } + ); + } +} diff --git a/Tests/Utils/TypeString/FilterTypesTest.php b/Tests/Utils/TypeString/FilterTypesTest.php new file mode 100644 index 00000000..41341dae --- /dev/null +++ b/Tests/Utils/TypeString/FilterTypesTest.php @@ -0,0 +1,489 @@ +expectError(); + } elseif (\PHP_VERSION_ID >= 70000) { + // PHP 7.0+ + $this->expectException('\TypeError'); + } else { + // PHP 5.6 with PHPUnit 5.2+ and PHPUnit Polyfills 2.x. + $this->expectException('\PHPUnit_Framework_Error'); + } + + TypeString::filterKeywordTypes($input); + } + + /** + * Test filterOOTypes() throws an exception when non-array data is passed. + * + * @dataProvider dataFilterNonArrayInput + * + * @param mixed $input The invalid input. + * + * @return void + */ + public function testFilterOOTypesNonArrayInput($input) + { + if (\method_exists($this, 'expectError')) { + // PHP 5.4 + 5.5 with PHPUnit Polyfills 1.x. + $this->expectError(); + } elseif (\PHP_VERSION_ID >= 70000) { + // PHP 7.0+ + $this->expectException('\TypeError'); + } else { + // PHP 5.6 with PHPUnit 5.2+ and PHPUnit Polyfills 2.x. + $this->expectException('\PHPUnit_Framework_Error'); + } + + TypeString::filterOOTypes($input); + } + + /** + * Data provider. + * + * @return array> + */ + public static function dataFilterNonArrayInput() + { + $data = TypeProviderHelper::getAll(); + unset( + $data['empty array'], + $data['array with values, no keys'], + $data['array with values, string keys'] + ); + + return $data; + } + + /** + * Test filterKeywordTypes() ignores non-string array entries completely. + * + * @return void + */ + public function testFilterKeywordTypesDisregardsNonStringEntries() + { + $types = [ + 'bool' => false, + 'float' => 1.5, + 'keyword' => 'string', + 'int' => 1.0, + 'iterable' => [1, 2, 3], + 'object' => new stdClass(), + 'classname' => '\Traversable', + ]; + + $expected = [ + 'keyword' => 'string', + ]; + + $this->assertSame($expected, TypeString::filterKeywordTypes($types)); + } + + /** + * Test filterOOTypes() ignores non-string array entries completely. + * + * @return void + */ + public function testFilterOOTypesDisregardsNonStringEntries() + { + $types = [ + 'bool' => false, + 'float' => 1.5, + 'int' => 1.0, + 'iterable' => [1, 2, 3], + 'classname' => '\Traversable', + 'object' => new stdClass(), + 'keyword' => 'string', + ]; + + $expected = [ + 'classname' => '\Traversable', + ]; + + $this->assertSame($expected, TypeString::filterOOTypes($types)); + } + + /** + * Test filterKeywordTypes() correctly filters out non-keyword types and maintains key association. + * + * @dataProvider dataFilterKeywordTypes + * + * @param array $types A types array. + * @param array $expected The expected function return value. + * + * @return void + */ + public function testFilterKeywordTypes($types, $expected) + { + $this->assertEqualsCanonicalizing($expected, TypeString::filterKeywordTypes($types)); + } + + /** + * Data provider. + * + * @return array>> + */ + public static function dataFilterKeywordTypes() + { + $baseData = self::dataFilterTypes(); + $data = []; + + foreach ($baseData as $key => $dataSet) { + $types = \array_merge($dataSet['keywords'], $dataSet['oonames']); + $types = self::shuffle($types); + + $data[$key] = [ + 'types' => $types, + 'expected' => $dataSet['keywords'], + ]; + } + + return $data; + } + + /** + * Test filterOOTypes() correctly filters out keyword types and maintains key association. + * + * @dataProvider dataFilterOOTypes + * + * @param array $types A types array. + * @param array $expected The expected function return value. + * + * @return void + */ + public function testFilterOOTypes($types, $expected) + { + $this->assertEqualsCanonicalizing($expected, TypeString::filterOOTypes($types)); + } + + /** + * Data provider. + * + * @return array>> + */ + public static function dataFilterOOTypes() + { + $baseData = self::dataFilterTypes(); + $data = []; + + foreach ($baseData as $key => $dataSet) { + $types = \array_merge($dataSet['keywords'], $dataSet['oonames']); + $types = self::shuffle($types); + + $data[$key] = [ + 'types' => $types, + 'expected' => $dataSet['oonames'], + ]; + } + + return $data; + } + + /** + * Data provider. + * + * @return array>> + */ + public static function dataFilterTypes() + { + return [ + 'empty array' => [ + 'keywords' => [], + 'oonames' => [], + ], + 'keyed array containing only keywords' => [ + 'keywords' => [ + 'array' => 'array', + 'bool' => 'bool', + 'callable' => 'callable', + 'false' => 'false', + 'float' => 'float', + 'int' => 'int', + 'iterable' => 'iterable', + 'mixed' => 'mixed', + 'never' => 'never', + 'null' => 'null', + 'object' => 'object', + 'parent' => 'parent', + 'self' => 'self', + 'static' => 'static', + 'string' => 'string', + 'true' => 'true', + 'void' => 'void', + ], + 'oonames' => [], + ], + 'keyed array containing only oo names' => [ + 'keywords' => [], + 'oonames' => [ + 'Foo' => 'Foo', + '\Bar' => '\Bar', + 'Partially\Qualified' => 'Partially\Qualified', + 'namespace\Relative' => 'namespace\Relative', + '\Fully\Qualified' => '\Fully\Qualified', + ], + ], + 'keyed array containing both keywords and oo names' => [ + 'keywords' => [ + 'array' => 'array', + 'bool' => 'bool', + 'callable' => 'callable', + 'false' => 'false', + 'float' => 'float', + 'int' => 'int', + 'iterable' => 'iterable', + 'mixed' => 'mixed', + 'never' => 'never', + 'null' => 'null', + 'object' => 'object', + 'parent' => 'parent', + 'self' => 'self', + 'static' => 'static', + 'string' => 'string', + 'true' => 'true', + 'void' => 'void', + ], + 'oonames' => [ + 'Foo' => 'Foo', + '\Bar' => '\Bar', + 'Partially\Qualified' => 'Partially\Qualified', + 'namespace\Relative' => 'namespace\Relative', + '\Fully\Qualified' => '\Fully\Qualified', + ], + ], + 'keyed array containing both keywords and oo names, keys not the same as values' => [ + 'keywords' => [ + 'float' => 'callable', + 'string' => 'int', + 'void' => 'never', + 'never' => 'null', + 'static' => 'self', + 'parent' => 'static', + 'true' => 'string', + ], + 'oonames' => [ + 'one' => 'Foo', + 'two' => '\Bar', + 'three' => 'Partially\Qualified', + 'four' => 'namespace\Relative', + 'five' => '\Fully\Qualified', + ], + ], + 'keyed array containing both keywords and oo names, keywords not case-normalized are returned as-is' => [ + 'keywords' => [ + 'ARRAY' => 'ARRAY', + 'False' => 'False', + 'iterable' => 'iterable', + 'MiXeD' => 'MiXeD', + 'parent' => 'Parent', + 'TRUE' => 'TRUE', + ], + 'oonames' => [ + 'Foo' => 'Foo', + '\Bar' => '\Bar', + '\Fully\Qualified' => '\Fully\Qualified', + ], + ], + 'keyed array containing both keywords and oo names, untrimmed values are returned as-is' => [ + 'keywords' => [ + 'iterable' => 'iterable ', + 'parent' => ' Parent ', + 'TRUE' => "\t\tTRUE", + ], + 'oonames' => [ + 'Foo' => 'Foo ', + '\Bar' => ' \Bar ', + '\Fully\Qualified' => ' \Fully\Qualified', + ], + ], + 'keyed array containing both keywords and oo names with duplicates, duplicates are included in return' => [ + 'keywords' => [ + 'float' => 'callable', + 'string' => 'int', + 'void' => 'never', + 'never' => 'int', + 'static' => 'self', + 'parent' => 'callable', + 'true' => 'never', + ], + 'oonames' => [ + 'one' => 'Foo', + 'two' => '\Bar', + 'three' => 'Partially\Qualified', + 'four' => '\Bar', + 'five' => 'Foo', + ], + ], + ]; + } + + /** + * Test filterKeywordTypes() correctly filters out non-keyword types and maintains + * key association even when the keys are numeric. + * + * @return void + */ + public function testFilterKeywordTypesKeyAssociationIsMaintainedEvenWhenNumeric() + { + $types = [ + 'array', + 'int', + '\Bar', + 'mixed', + 'Partially\Qualified', + 'parent', + 'true', + 'namespace\Relative', + ]; + + $expected = [ + 0 => 'array', + 1 => 'int', + 3 => 'mixed', + 5 => 'parent', + 6 => 'true', + ]; + + $this->assertSame($expected, TypeString::filterKeywordTypes($types)); + } + + /** + * Test filterOOTypes() correctly filters out keyword types and maintains + * key association even when the keys are numeric. + * + * @return void + */ + public function testFilterOOTypesKeyAssociationIsMaintainedEvenWhenNumeric() + { + $types = [ + 'array', + 'int', + '\Bar', + 'mixed', + 'Partially\Qualified', + 'parent', + 'true', + 'namespace\Relative', + ]; + + $expected = [ + 2 => '\Bar', + 4 => 'Partially\Qualified', + 7 => 'namespace\Relative', + ]; + + $this->assertSame($expected, TypeString::filterOOTypes($types)); + } + + /** + * Test filterKeywordTypes() correctly filters out non-keyword types from a numerically + * keyed input and doesn't remove duplicates. + * + * @return void + */ + public function testFilterKeywordTypesDuplicateHandlingWithNumericKeys() + { + $types = [ + 'int', + '\Bar', + 'mixed', + 'Partially\Qualified', + 'int', + 'Partially\Qualified', + ]; + + $expected = [ + 0 => 'int', + 2 => 'mixed', + 4 => 'int', + ]; + + $this->assertSame($expected, TypeString::filterKeywordTypes($types)); + } + + /** + * Test filterOOTypes() correctly filters out keyword types from a numerically + * keyed input and doesn't remove duplicates. + * + * @return void + */ + public function testFilterOOTypesDuplicateHandlingWithNumericKeys() + { + $types = [ + 'int', + '\Bar', + 'mixed', + 'Partially\Qualified', + 'int', + 'Partially\Qualified', + ]; + + $expected = [ + 1 => '\Bar', + 3 => 'Partially\Qualified', + 5 => 'Partially\Qualified', + ]; + + $this->assertSame($expected, TypeString::filterOOTypes($types)); + } + + /** + * Helper function: shuffle array while preserving key association. + * + * @param array $types The array to shuffle. + * + * @return array + */ + private static function shuffle(array $types) + { + $keys = \array_keys($types); + + \shuffle($keys); + + $shuffled = []; + foreach ($keys as $key) { + $shuffled[$key] = $types[$key]; + } + + return $shuffled; + } +} diff --git a/Tests/Utils/TypeString/GetKeywordTypesTest.php b/Tests/Utils/TypeString/GetKeywordTypesTest.php new file mode 100644 index 00000000..3ec56e71 --- /dev/null +++ b/Tests/Utils/TypeString/GetKeywordTypesTest.php @@ -0,0 +1,38 @@ +assertIsArray($list, 'Return value was not an array'); + $this->assertCount(17, $list, 'Returned array did not contain the expected number of items'); + } +} diff --git a/Tests/Utils/TypeString/IsKeywordTest.php b/Tests/Utils/TypeString/IsKeywordTest.php new file mode 100644 index 00000000..e73f8e22 --- /dev/null +++ b/Tests/Utils/TypeString/IsKeywordTest.php @@ -0,0 +1,189 @@ +assertFalse(TypeString::isKeyword($input)); + } + + /** + * Data provider. + * + * @return array> + */ + public static function dataIsKeywordNonStringInput() + { + $data = TypeProviderHelper::getAll(); + unset( + $data['empty string'], + $data['numeric string'], + $data['textual string'], + $data['textual string starting with numbers'] + ); + + return $data; + } + + /** + * Test isKeyword() returns "true" for PHP native keyword based types. + * + * @dataProvider dataIsKeywordValid + * + * @param string $type The type. + * + * @return void + */ + public function testIsKeywordValid($type) + { + $this->assertTrue(TypeString::isKeyword($type)); + } + + /** + * Data provider. + * + * @return array> + */ + public static function dataIsKeywordValid() + { + $data = []; + $types = [ + // Valid keyword types. + 'array' => 'array', + 'bool' => 'bool', + 'callable' => 'callable', + 'false' => 'false', + 'float' => 'float', + 'int' => 'int', + 'iterable' => 'iterable', + 'mixed' => 'mixed', + 'never' => 'never', + 'null' => 'null', + 'object' => 'object', + 'parent' => 'parent', + 'self' => 'self', + 'static' => 'static', + 'string' => 'string', + 'true' => 'true', + 'void' => 'void', + ]; + + foreach ($types as $type => $expected) { + $data[$type . ': lowercase'] = [ + 'type' => $type, + ]; + + $data[$type . ': uppercase'] = [ + 'type' => \strtoupper($type), + ]; + + $data[$type . ': mixed case'] = [ + 'type' => \ucfirst($type), + ]; + + $data[$type . ': surrounding whitespace'] = [ + 'type' => " $type ", + ]; + } + + return $data; + } + + /** + * Test isKeyword() returns "false" for non-keyword based types. + * + * @dataProvider dataIsKeywordInvalid + * + * @param string $type The type. + * + * @return void + */ + public function testIsKeywordInvalid($type) + { + $this->assertFalse(TypeString::isKeyword($type)); + } + + /** + * Data provider. + * + * @return array> + */ + public static function dataIsKeywordInvalid() + { + return [ + 'empty string' => [ + 'type' => '', + ], + 'string containing only whitespace' => [ + 'type' => ' ', + ], + 'string which isn\'t a type string' => [ + 'type' => 'Roll, roll, roll your boat', + ], + 'typestring which hasn\'t been split yet (union)' => [ + 'type' => 'true|string|float', + ], + 'typestring which hasn\'t been split yet (intersection)' => [ + 'type' => 'A&B', + ], + 'typestring which hasn\'t been split yet (DNF)' => [ + 'type' => '(A&B)|false', + ], + 'Classname: Boolean' => [ + 'type' => 'Boolean', + ], + 'Classname: Integer' => [ + 'type' => 'Integer', + ], + 'Classname: Traversable with surrounding whitespace' => [ + 'type' => ' Traversable ' . "\n\t\n", + ], + 'Classname: Package\Int' => [ + 'type' => 'Package\Int', + ], + 'Classname: namespace\Relative\Name' => [ + 'type' => 'namespace\Relative\Name', + ], + 'Classname: \Fully\Qualified\Name' => [ + 'type' => '\Fully\Qualified\Name', + ], + 'Classname: Пасха (non-ascii chars)' => [ + 'type' => 'Пасха', + ], + 'Classname: 😎 (non-ascii chars/emoji name)' => [ + 'type' => '😎', + ], + ]; + } +} diff --git a/Tests/Utils/TypeString/IsTypeTest.php b/Tests/Utils/TypeString/IsTypeTest.php new file mode 100644 index 00000000..33a1c44f --- /dev/null +++ b/Tests/Utils/TypeString/IsTypeTest.php @@ -0,0 +1,1030 @@ +assertFalse(TypeString::isSingular($input)); + } + + /** + * Test isNullable() returns false when non-string data is passed. + * + * @dataProvider dataNonStringInput + * + * @param mixed $input The invalid input. + * + * @return void + */ + public function testIsNullableNonStringInput($input) + { + $this->assertFalse(TypeString::isNullable($input)); + } + + /** + * Test isUnion() returns false when non-string data is passed. + * + * @dataProvider dataNonStringInput + * + * @param mixed $input The invalid input. + * + * @return void + */ + public function testIsUnionNonStringInput($input) + { + $this->assertFalse(TypeString::isUnion($input)); + } + + /** + * Test isIntersection() returns false when non-string data is passed. + * + * @dataProvider dataNonStringInput + * + * @param mixed $input The invalid input. + * + * @return void + */ + public function testIsIntersectionNonStringInput($input) + { + $this->assertFalse(TypeString::isIntersection($input)); + } + + /** + * Test isDNF() returns false when non-string data is passed. + * + * @dataProvider dataNonStringInput + * + * @param mixed $input The invalid input. + * + * @return void + */ + public function testIsDNFNonStringInput($input) + { + $this->assertFalse(TypeString::isDNF($input)); + } + + /** + * Data provider. + * + * @return array> + */ + public static function dataNonStringInput() + { + $data = TypeProviderHelper::getAll(); + unset( + $data['empty string'], + $data['numeric string'], + $data['textual string'], + $data['textual string starting with numbers'] + ); + + return $data; + } + + /** + * Test isSingular(). + * + * @dataProvider dataStringNotTypeString + * @dataProvider dataTypeStrings + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testIsSingular($type, $expected) + { + $this->assertSame($expected['singular'], TypeString::isSingular($type)); + } + + /** + * Test isNullable(). + * + * @dataProvider dataTypeStrings + * @dataProvider dataStringNotTypeString + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testIsNullable($type, $expected) + { + $this->assertSame($expected['nullable'], TypeString::isNullable($type)); + } + + /** + * Test isUnion(). + * + * @dataProvider dataTypeStrings + * @dataProvider dataStringNotTypeString + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testIsUnion($type, $expected) + { + $this->assertSame($expected['union'], TypeString::isUnion($type)); + } + + /** + * Test isIntersection(). + * + * @dataProvider dataTypeStrings + * @dataProvider dataStringNotTypeString + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testIsIntersection($type, $expected) + { + $this->assertSame($expected['intersection'], TypeString::isIntersection($type)); + } + + /** + * Test isDNF(). + * + * @dataProvider dataTypeStrings + * @dataProvider dataStringNotTypeString + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testIsDNF($type, $expected) + { + $this->assertSame($expected['dnf'], TypeString::isDNF($type)); + } + + /** + * Data provider. + * + * @return array>> + */ + public static function dataTypeStrings() + { + return [ + 'plain singular type: null' => [ + 'type' => 'null', + 'expected' => [ + 'singular' => true, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'plain singular type: callable' => [ + 'type' => 'callable', + 'expected' => [ + 'singular' => true, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'plain singular type: Countable' => [ + 'type' => 'Countable', + 'expected' => [ + 'singular' => true, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'plain singular type: \ClassNameContainingNullInIt' => [ + 'type' => '\ClassNameContainingNullInIt', + 'expected' => [ + 'singular' => true, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'plain singular type: \Class_With_Null_In_It' => [ + 'type' => '\Class_With_Null_In_It', + 'expected' => [ + 'singular' => true, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'nullable plain type: ?string' => [ + 'type' => '?string', + 'expected' => [ + 'singular' => false, + 'nullable' => true, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'nullable plain type: ? ClassName (whitespace)' => [ + 'type' => '? ClassName', + 'expected' => [ + 'singular' => false, + 'nullable' => true, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'union type: all types' => [ + 'type' => 'array|bool|callable|false|FLOAT|Int|iterable|miXed|never|null|object|parent|' + . 'Self|static|string|true|void', + 'expected' => [ + 'singular' => false, + 'nullable' => true, + 'union' => true, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'union type: UnqualifiedName|Package\Partially|\Vendor\FullyQualified|namespace\Relative\Name' => [ + 'type' => 'UnqualifiedName|Package\Partially|\Vendor\FullyQualified|namespace\Relative\Name', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => true, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'nullable union type: NULL|INT (capitalized)' => [ + 'type' => 'NULL|INT', + 'expected' => [ + 'singular' => false, + 'nullable' => true, + 'union' => true, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'nullable union type: true | null (whitespace)' => [ + 'type' => 'true | null', + 'expected' => [ + 'singular' => false, + 'nullable' => true, + 'union' => true, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'intersection type: UnqualifiedName&Package\Partially&\Vendor\FullyQualified&namespace\Relative\Name' => [ + 'type' => 'UnqualifiedName&Package\Partially&\Vendor\FullyQualified&namespace\Relative\Name', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => true, + 'dnf' => false, + ], + ], + 'intersection type: bool&never (invalid)' => [ + 'type' => 'bool&never', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => true, + 'dnf' => false, + ], + ], + 'intersection type: Foo&null (invalid)' => [ + 'type' => 'Foo&null', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => true, + 'dnf' => false, + ], + ], + + 'DNF type: string|(Foo&Bar)|int' => [ + 'type' => 'string|(Foo&Bar)|int', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => true, + ], + ], + 'DNF type: null at end' => [ + 'type' => '(Foo&Bar)|null', + 'expected' => [ + 'singular' => false, + 'nullable' => true, + 'union' => false, + 'intersection' => false, + 'dnf' => true, + ], + ], + 'DNF type: null in the middle' => [ + 'type' => '(Foo&Bar)|null|(Baz&Countable)', + 'expected' => [ + 'singular' => false, + 'nullable' => true, + 'union' => false, + 'intersection' => false, + 'dnf' => true, + ], + ], + 'DNF type: null at start and whitespace rich' => [ + 'type' => ' null | ( Foo & Bar & \Baz )', + 'expected' => [ + 'singular' => false, + 'nullable' => true, + 'union' => false, + 'intersection' => false, + 'dnf' => true, + ], + ], + ]; + } + + /** + * Data provider. + * + * @return array>> + */ + public static function dataStringNotTypeString() + { + return [ + 'empty string' => [ + 'type' => '', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'string containing only whitespace' => [ + 'type' => ' ', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + /* + * Note: the methods don't check if a "type" is a valid identifier or if the format used is valid in PHP, + * only that the type "looks like" a certain supported PHP type construct. + * If a stricter check is needed, use the NamingConventions::isValidIdentifierName() method + * for checking the individual types and token walking for checking the format. + */ + 'not a type string' => [ + 'type' => 'Roll roll roll your boat', + 'expected' => [ + 'singular' => true, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string with commas' => [ + 'type' => 'Roll, roll, roll your boat', + 'expected' => [ + 'singular' => true, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'not a type string: only question mark (with whitespace)' => [ + 'type' => ' ? ', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only question marks' => [ + 'type' => '? ?', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only question mark and pipe' => [ + 'type' => '?|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only question mark and ampersand' => [ + 'type' => '? &', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only question mark and open parenthesis' => [ + 'type' => '?(', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only question mark and close parenthesis' => [ + 'type' => '? )', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: nullable ? not at start' => [ + 'type' => 'Some?\Class', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: nullable ? at end' => [ + 'type' => 'int?', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'not a type string: only pipe' => [ + 'type' => '|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only pipes' => [ + 'type' => '||', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only pipe and question mark' => [ + 'type' => '|?', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only pipe and ampersand' => [ + 'type' => '|&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only pipe and open parenthesis' => [ + 'type' => '|(', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only pipe and close parenthesis' => [ + 'type' => '|)', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: pipe with something before, not after' => [ + 'type' => 'Something |', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: pipe with something after, not before' => [ + 'type' => '|Something', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: pipe with null before, nothing after' => [ + 'type' => 'null |', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: pipe with null after, nothing before' => [ + 'type' => '|null', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: multi-union pipe with null before, nothing after' => [ + 'type' => 'Something | null |', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: multi-union pipe with null after, nothing before' => [ + 'type' => '|null|Something', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'not a type string: only ampersand' => [ + 'type' => '&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only ampersands' => [ + 'type' => '&&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only ampersand and question mark' => [ + 'type' => '&?', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only ampersand and pipe' => [ + 'type' => '&|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only ampersand and open parenthesis' => [ + 'type' => '&(', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only ampersand and close parenthesis' => [ + 'type' => '&)', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: ampersand with something before, not after' => [ + 'type' => 'Something&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: ampersand with something after, not before' => [ + 'type' => '& Something', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: ampersand with null before, nothing after' => [ + 'type' => 'null&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: ampersand with null after, nothing before' => [ + 'type' => '& null', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: multi-intersect ampersand with null before, nothing after' => [ + 'type' => 'Something&null&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: multi-intersect ampersand with null after, nothing before' => [ + 'type' => '&null&Something', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'invalid type string: |null|' => [ + 'type' => '|null|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'invalid type string: &null&' => [ + 'type' => '&null&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'not a type string: only open parenthesis' => [ + 'type' => '(', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only close parenthesis' => [ + 'type' => ')', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only open parentheses' => [ + 'type' => '((', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only close parentheses' => [ + 'type' => '))', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only parentheses ()' => [ + 'type' => '()', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only parentheses )(' => [ + 'type' => ')(', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only open parenthesis and question mark' => [ + 'type' => '(?', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only open parenthesis and pipe' => [ + 'type' => '(|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only open parenthesis and ampersand' => [ + 'type' => '(&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only close parenthesis and question mark' => [ + 'type' => ')&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only close parenthesis and pipe' => [ + 'type' => ')|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: only close parenthesis and ampersand' => [ + 'type' => ')&', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: open + close parentheses and ampersand' => [ + 'type' => '(&)', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: open + close parentheses and pipe' => [ + 'type' => '()|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: open parenthesis, pipe and ampersand' => [ + 'type' => '(&|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: close parenthesis, pipe and ampersand' => [ + 'type' => ')&|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'not a type string: open + close parentheses, pipe and ampersand' => [ + 'type' => '(&)|', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + + 'Invalid DNF type: ?(Foo&Bar)' => [ + 'type' => '?(Foo&Bar)', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'Invalid DNF type: (Foo|Bar)&Baz' => [ + 'type' => '(Foo|Bar)&Baz', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'Invalid DNF type: Foo&Bar|string' => [ + 'type' => 'Foo&Bar|string', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'Invalid DNF type: (Foo&Bar|string)' => [ + 'type' => '(Foo&Bar|string)', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => true, + ], + ], + 'Incomplete DNF type: string|(Foo&Bar (missing close parentheses)' => [ + 'type' => 'string|(Foo&Bar', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + 'Incomplete DNF type: string|Foo&Bar) (missing open parentheses)' => [ + 'type' => 'string|Foo&Bar)', + 'expected' => [ + 'singular' => false, + 'nullable' => false, + 'union' => false, + 'intersection' => false, + 'dnf' => false, + ], + ], + ]; + } +} diff --git a/Tests/Utils/TypeString/NormalizeCaseTest.php b/Tests/Utils/TypeString/NormalizeCaseTest.php new file mode 100644 index 00000000..0133f95a --- /dev/null +++ b/Tests/Utils/TypeString/NormalizeCaseTest.php @@ -0,0 +1,196 @@ +assertSame('', TypeString::normalizeCase($input)); + } + + /** + * Data provider. + * + * @see testNormalizeCaseReturnsEmptyStringOnNonStringInput() For the array format. + * + * @return array> + */ + public static function dataNormalizeCaseReturnsEmptyStringOnNonStringInput() + { + $data = TypeProviderHelper::getAll(); + unset( + $data['empty string'], + $data['numeric string'], + $data['textual string'], + $data['textual string starting with numbers'] + ); + + return $data; + } + + /** + * Test case normalization. + * + * Includes tests safeguarding that case normalization does not change the whitespace in the string. + * + * @dataProvider dataNormalizeCase + * + * @param string $type The type. + * @param string $expected The expected function output. + * + * @return void + */ + public function testNormalizeCase($type, $expected) + { + $this->assertSame($expected, TypeString::normalizeCase($type)); + } + + /** + * Data provider. + * + * @see testNormalizeCase() For the array format. + * + * @return array> + */ + public static function dataNormalizeCase() + { + $data = []; + $data['empty string'] = [ + 'type' => '', + 'expected' => '', + ]; + $data['string containing only whitespace'] = [ + 'type' => ' ', + 'expected' => ' ', + ]; + $data['string which isn\'t a type string'] = [ + 'type' => 'Roll, roll, roll your boat', + 'expected' => 'Roll, roll, roll your boat', + ]; + + $types = [ + 'array' => 'array', + 'bool' => 'bool', + 'callable' => 'callable', + 'false' => 'false', + 'float' => 'float', + 'int' => 'int', + 'iterable' => 'iterable', + 'mixed' => 'mixed', + 'never' => 'never', + 'null' => 'null', + 'object' => 'object', + 'parent' => 'parent', + 'self' => 'self', + 'static' => 'static', + 'string' => 'string', + 'true' => 'true', + 'void' => 'void', + ]; + + foreach ($types as $type => $expected) { + $data['Keyword ' . $type . ': lowercase'] = [ + 'type' => $type, + 'expected' => $expected, + ]; + + $data['Keyword ' . $type . ': uppercase'] = [ + 'type' => \strtoupper($type), + 'expected' => $expected, + ]; + + $data['Keyword ' . $type . ': mixed case'] = [ + 'type' => \ucfirst($type), + 'expected' => $expected, + ]; + } + + $data['Classname: UnqualifiedName'] = [ + 'type' => 'UnqualifiedName', + 'expected' => 'UnqualifiedName', + ]; + + $data['Classname: Package\Partially'] = [ + 'type' => 'Package\Partially', + 'expected' => 'Package\Partially', + ]; + + $data['Classname: \Vendor\Package\FullyQualified'] = [ + 'type' => '\Vendor\Package\FullyQualified', + 'expected' => '\Vendor\Package\FullyQualified', + ]; + + $data['Classname: namespace\Relative\Name'] = [ + 'type' => 'namespace\Relative\Name', + 'expected' => 'namespace\Relative\Name', + ]; + + $data['Classname: Пасха (non-ascii chars)'] = [ + 'type' => 'Пасха', + 'expected' => 'Пасха', + ]; + + $data['Classname: 😎 (non-ascii chars/emoji name)'] = [ + 'type' => '😎', + 'expected' => '😎', + ]; + + // Document whitespace handling: whitespace will not be changed by this method. + $data['Keyword iterable: lowercase - surrounding whitespace is not changed'] = [ + 'type' => ' iterable ', + 'expected' => ' iterable ', + ]; + + $data['Keyword static: uppercase - surrounding whitespace is not changed'] = [ + 'type' => ' STATIC ', + 'expected' => ' static ', + ]; + + $data['Keyword bool: mixed case - surrounding whitespace is not changed'] = [ + 'type' => " Bool \t\n", + 'expected' => " bool \t\n", + ]; + + $data['Classname: Traversable - surrounding whitespace is not changed'] = [ + 'type' => ' Traversable ' . "\n\t\n", + 'expected' => ' Traversable ' . "\n\t\n", + ]; + + $data['Classname: \Vendor\Package\FullyQualified - whitespace within name is not changed'] = [ + 'type' => '\Vendor \ Package \ FullyQualified', + 'expected' => '\Vendor \ Package \ FullyQualified', + ]; + + return $data; + } +} diff --git a/Tests/Utils/TypeString/ToArrayTest.php b/Tests/Utils/TypeString/ToArrayTest.php new file mode 100644 index 00000000..f349cd22 --- /dev/null +++ b/Tests/Utils/TypeString/ToArrayTest.php @@ -0,0 +1,564 @@ +assertSame([], TypeString::toArray($input)); + } + + /** + * Test toArrayUnique() returns an empty array when non-string data is passed. + * + * @dataProvider dataToArrayReturnsEmptyArrayOnNonStringInput + * + * @param mixed $input The invalid input. + * + * @return void + */ + public function testToArrayUniqueReturnsEmptyArrayOnNonStringInput($input) + { + $this->assertSame([], TypeString::toArrayUnique($input)); + } + + /** + * Data provider. + * + * @see testToArrayReturnsEmptyArrayOnNonStringInput() For the array format. + * + * @return array> + */ + public static function dataToArrayReturnsEmptyArrayOnNonStringInput() + { + $data = TypeProviderHelper::getAll(); + unset( + $data['empty string'], + $data['numeric string'], + $data['textual string'], + $data['textual string starting with numbers'] + ); + + return $data; + } + + /** + * Test type string to array conversion. + * + * @dataProvider dataToArray + * @dataProvider dataToArrayNormalized + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testToArrayAndNormalize($type, $expected) + { + $this->assertSame(\array_values($expected), TypeString::toArray($type, true)); + } + + /** + * Test type string to array conversion. + * + * @dataProvider dataToArray + * @dataProvider dataToArrayNotNormalized + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testToArrayDontNormalize($type, $expected) + { + $this->assertSame(\array_values($expected), TypeString::toArray($type, false)); + } + + /** + * Test type string to array conversion with de-duplication. + * + * @dataProvider dataToArray + * @dataProvider dataToArrayNormalized + * @dataProvider dataToArrayUniqueNormalized + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testToArrayUniqueAndNormalize($type, $expected) + { + $this->assertSame($expected, TypeString::toArrayUnique($type, true)); + } + + /** + * Test type string to array conversion with de-duplication. + * + * @dataProvider dataToArray + * @dataProvider dataToArrayNotNormalized + * @dataProvider dataToArrayUniqueNotNormalized + * + * @param string $type The type string. + * @param array $expected The expected function output. + * + * @return void + */ + public function testToArrayUniqueDontNormalize($type, $expected) + { + $this->assertSame($expected, TypeString::toArrayUnique($type, false)); + } + + /** + * Data provider: input for which normalization is irrelevant. + * + * @see testToArray() For the array format. + * + * @return array>> + */ + public static function dataToArray() + { + return [ + 'empty string' => [ + 'type' => '', + 'expected' => [], + ], + 'string containing only whitespace' => [ + 'type' => ' ', + 'expected' => [], + ], + + 'simple singular type: callable' => [ + 'type' => 'callable', + 'expected' => [ + 'callable' => 'callable', + ], + ], + 'nullable type: ?string' => [ + 'type' => '?string', + 'expected' => [ + 'null' => 'null', + 'string' => 'string', + ], + ], + 'nullable type with whitespace: ? ClassName' => [ + 'type' => '? ClassName', + 'expected' => [ + 'null' => 'null', + 'ClassName' => 'ClassName', + ], + ], + 'nullable type: ?boolean (invalid/interpreted as classname)' => [ + 'type' => '?boolean', + 'expected' => [ + 'null' => 'null', + 'boolean' => 'boolean', + ], + ], + 'nullable type: ?void (invalid)' => [ + 'type' => '?void', + 'expected' => [ + 'null' => 'null', + 'void' => 'void', + ], + ], + + 'union type: all types' => [ + 'type' => 'array|bool|callable|false|float|int|iterable|mixed|never|null|object|parent|' + . 'self|static|string|true|void', + 'expected' => [ + 'array' => 'array', + 'bool' => 'bool', + 'callable' => 'callable', + 'false' => 'false', + 'float' => 'float', + 'int' => 'int', + 'iterable' => 'iterable', + 'mixed' => 'mixed', + 'never' => 'never', + 'null' => 'null', + 'object' => 'object', + 'parent' => 'parent', + 'self' => 'self', + 'static' => 'static', + 'string' => 'string', + 'true' => 'true', + 'void' => 'void', + ], + ], + 'union type: UnqualifiedName|Package\Partially|\Vendor\FullyQualified|namespace\Relative\Name' => [ + 'type' => 'UnqualifiedName|Package\Partially|\Vendor\FullyQualified|namespace\Relative\Name', + 'expected' => [ + 'UnqualifiedName' => 'UnqualifiedName', + 'Package\Partially' => 'Package\Partially', + '\Vendor\FullyQualified' => '\Vendor\FullyQualified', + 'namespace\Relative\Name' => 'namespace\Relative\Name', + ], + ], + 'union type: true | null | void (invalid + whitespace)' => [ + 'type' => 'true | null | void', + 'expected' => [ + 'true' => 'true', + 'null' => 'null', + 'void' => 'void', + ], + ], + + 'intersection type: UnqualifiedName&Package\Partially&\Vendor \FullyQualified & namespace\Relative\ Name' => [ + 'type' => 'UnqualifiedName&Package\Partially&\Vendor \FullyQualified & namespace\Relative\ Name', + 'expected' => [ + 'UnqualifiedName' => 'UnqualifiedName', + 'Package\Partially' => 'Package\Partially', + '\Vendor\FullyQualified' => '\Vendor\FullyQualified', + 'namespace\Relative\Name' => 'namespace\Relative\Name', + ], + ], + 'intersection type: Foo & Bar (whitespace)' => [ + 'type' => 'Foo & Bar', + 'expected' => [ + 'Foo' => 'Foo', + 'Bar' => 'Bar', + ], + ], + 'intersection type: bool&never (invalid)' => [ + 'type' => 'bool&never', + 'expected' => [ + 'bool' => 'bool', + 'never' => 'never', + ], + ], + + 'DNF type: (A&B)|D' => [ + 'type' => '(A&B)|D', + 'expected' => [ + 'A' => 'A', + 'B' => 'B', + 'D' => 'D', + ], + ], + 'DNF type: C | ( \Fully\Qualified & Partially\Qualified ) | null (whitespace)' => [ + 'type' => 'C | ( \Fully\Qualified & Partially\Qualified ) | null', + 'expected' => [ + 'C' => 'C', + '\Fully\Qualified' => '\Fully\Qualified', + 'Partially\Qualified' => 'Partially\Qualified', + 'null' => 'null', + ], + ], + 'DNF type: int|null|(A&B&D)' => [ + 'type' => 'int|null|(A&B&D)', + 'expected' => [ + 'int' => 'int', + 'null' => 'null', + 'A' => 'A', + 'B' => 'B', + 'D' => 'D', + ], + ], + 'DNF type: (B&A)|null|(namespace\Relative&Unqualified)|false|(C&D)' => [ + 'type' => '(B&A)|null|(namespace\Relative&Unqualified)|false|(C&D)', + 'expected' => [ + 'B' => 'B', + 'A' => 'A', + 'null' => 'null', + 'namespace\Relative' => 'namespace\Relative', + 'Unqualified' => 'Unqualified', + 'false' => 'false', + 'C' => 'C', + 'D' => 'D', + ], + ], + 'DNF type: (A&B) (invalid, parens not needed)' => [ + 'type' => '(A&B)', + 'expected' => [ + 'A' => 'A', + 'B' => 'B', + ], + ], + 'DNF type: A&(B|D) (invalid, parse error)' => [ + 'type' => 'A&(B|D)', + 'expected' => [ + 'A' => 'A', + 'B' => 'B', + 'D' => 'D', + ], + ], + 'DNF type: A|(B&(D|W)|null) (invalid, parse error)' => [ + 'type' => 'A|(B&(D|W)|null)', + 'expected' => [ + 'A' => 'A', + 'B' => 'B', + 'D' => 'D', + 'W' => 'W', + 'null' => 'null', + ], + ], + ]; + } + + /** + * Data provider: input for which normalization makes a difference. + * + * @see testToArray() For the array format. + * + * @return array>> + */ + public static function dataToArrayNormalized() + { + return [ + 'simple singular type, mixed case' => [ + 'type' => 'FlOaT', + 'expected' => [ + 'float' => 'float', + ], + ], + 'union type: all types, some using non-standard case' => [ + 'type' => 'array|bool|callable|false|FLOAT|Int|iterable|miXed|never|null|object|parent|' + . 'Self|static|string|TRUE|void', + 'expected' => [ + 'array' => 'array', + 'bool' => 'bool', + 'callable' => 'callable', + 'false' => 'false', + 'float' => 'float', + 'int' => 'int', + 'iterable' => 'iterable', + 'mixed' => 'mixed', + 'never' => 'never', + 'null' => 'null', + 'object' => 'object', + 'parent' => 'parent', + 'self' => 'self', + 'static' => 'static', + 'string' => 'string', + 'true' => 'true', + 'void' => 'void', + ], + ], + 'DNF type: keywords in mixed case' => [ + 'type' => 'FALSE|(B&A)|Null', + 'expected' => [ + 'false' => 'false', + 'B' => 'B', + 'A' => 'A', + 'null' => 'null', + ], + ], + ]; + } + + /** + * Data provider: input for which normalization makes a difference. + * + * @see testToArray() For the array format. + * + * @return array>> + */ + public static function dataToArrayNotNormalized() + { + return [ + 'simple singular type, mixed case' => [ + 'type' => 'FlOaT', + 'expected' => [ + 'FlOaT' => 'FlOaT', + ], + ], + 'union type: all types, some using non-standard case' => [ + 'type' => 'array|bool|callable|false|FLOAT|Int|iterable|miXed|never|null|object|parent|' + . 'Self|static|string|TRUE|void', + 'expected' => [ + 'array' => 'array', + 'bool' => 'bool', + 'callable' => 'callable', + 'false' => 'false', + 'FLOAT' => 'FLOAT', + 'Int' => 'Int', + 'iterable' => 'iterable', + 'miXed' => 'miXed', + 'never' => 'never', + 'null' => 'null', + 'object' => 'object', + 'parent' => 'parent', + 'Self' => 'Self', + 'static' => 'static', + 'string' => 'string', + 'TRUE' => 'TRUE', + 'void' => 'void', + ], + ], + 'DNF type: keywords in mixed case' => [ + 'type' => 'FALSE|(B&A)|Null', + 'expected' => [ + 'FALSE' => 'FALSE', + 'B' => 'B', + 'A' => 'A', + 'Null' => 'Null', + ], + ], + ]; + } + + /** + * Data provider: input for which filtering unique types makes a difference when the data is normalized. + * + * @see testToArray() For the array format. + * + * @return array>> + */ + public static function dataToArrayUniqueNormalized() + { + return [ + 'union type with duplicates: different case' => [ + 'type' => 'FlOaT|null|float|NULL', + 'expected' => [ + 'float' => 'float', + 'null' => 'null', + ], + ], + 'union type with duplicates: same case' => [ + 'type' => 'float|null|float|null', + 'expected' => [ + 'float' => 'float', + 'null' => 'null', + ], + ], + + // Normalization makes no difference for OO types, even though the classes are effectively + // the same due to the OO case handling in PHP, but that's not the concern of these methods. + 'intersection type with duplicates: different case' => [ + 'type' => 'FooBar&\Baz&foobar', + 'expected' => [ + 'FooBar' => 'FooBar', + '\Baz' => '\Baz', + 'foobar' => 'foobar', + ], + ], + 'intersection type with duplicates: same case' => [ + 'type' => 'FooBar&\Baz&FooBar', + 'expected' => [ + 'FooBar' => 'FooBar', + '\Baz' => '\Baz', + ], + ], + + 'DNF type with duplicates: different case' => [ + 'type' => '(A&B)|FALSE|(C&a)|false', + 'expected' => [ + 'A' => 'A', + 'B' => 'B', + 'false' => 'false', + 'C' => 'C', + 'a' => 'a', + ], + ], + 'DNF type with duplicates: same case' => [ + 'type' => '(A&B)|false|(C&A)|false', + 'expected' => [ + 'A' => 'A', + 'B' => 'B', + 'false' => 'false', + 'C' => 'C', + ], + ], + ]; + } + + /** + * Data provider: input for which filtering unique types makes a difference when the data is not normalized + * + * @see testToArray() For the array format. + * + * @return array>> + */ + public static function dataToArrayUniqueNotNormalized() + { + return [ + 'union type with duplicates: different case' => [ + 'type' => 'FlOaT|null|float|NULL', + 'expected' => [ + 'FlOaT' => 'FlOaT', + 'null' => 'null', + 'float' => 'float', + 'NULL' => 'NULL', + ], + ], + 'union type with duplicates: same case' => [ + 'type' => 'float|null|float|null', + 'expected' => [ + 'float' => 'float', + 'null' => 'null', + ], + ], + + // Normalization makes no difference for OO types, even though the classes are effectively + // the same due to the OO case handling in PHP, but that's not the concern of these methods. + 'intersection type with duplicates: different case' => [ + 'type' => 'FooBar&\Baz&foobar', + 'expected' => [ + 'FooBar' => 'FooBar', + '\Baz' => '\Baz', + 'foobar' => 'foobar', + ], + ], + 'intersection type with duplicates: same case' => [ + 'type' => 'FooBar&\Baz&FooBar', + 'expected' => [ + 'FooBar' => 'FooBar', + '\Baz' => '\Baz', + ], + ], + + 'DNF type with duplicates: different case' => [ + 'type' => '(A&B)|FALSE|(C&a)|false', + 'expected' => [ + 'A' => 'A', + 'B' => 'B', + 'FALSE' => 'FALSE', + 'C' => 'C', + 'a' => 'a', + 'false' => 'false', + ], + ], + 'DNF type with duplicates: same case' => [ + 'type' => '(A&B)|false|(C&A)|false', + 'expected' => [ + 'A' => 'A', + 'B' => 'B', + 'false' => 'false', + 'C' => 'C', + ], + ], + ]; + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8d42ecc5..8db74a0d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -59,6 +59,12 @@ parameters: path: PHPCSUtils/Tokens/Collections.php count: 1 + # Ignoring this as availability depends on which PHPUnit Polyfills version is loaded/installed. This is 100% okay. + - + message: "`^Call to function method_exists\\(\\) with \\$this\\([^)]+\\) and 'expectError' will always evaluate to false\\.$`" + path: Tests/Utils/TypeString/FilterTypesTest.php + count: 2 + # This depends on the PHP version on which PHPStan is being run, so not valid. - message: "`^Comparison operation \"\\>\\=\" between '[0-9\\. -]+' and 10 is always true\\.$`"