From b79838bd6eee24118d629e97784565c1a311b880 Mon Sep 17 00:00:00 2001 From: jrfnl Date: Sun, 28 Apr 2024 21:35:38 +0200 Subject: [PATCH] :sparkles: New TypeString utility class ... to contain utilities methods for analysing typestrings as retrieved via the `getParameters()`/`getProperties()` methods. Initially, the class comes with the following methods: * `getKeywordTypes(): array` to retrieve an array of all currently supported type keywords. * `isKeyword(string $type): bool` to determine whether a type is a keyword type. * `normalizeCase(string $type): string` to normalize the case of keyword types to lowercase, while leaving the case of OO-based types alone. * `isSingular(string $typeString): bool` to determine if a type is a purely singular type. * `isNullable(string $typeString): bool` to determine if a type is a nullable type, either by it having the nullable type operator or by including `null` as part of a union or DNF type. * `isUnion(string $typeString): bool` to determine if a type is a union type. * `isIntersection(string $typeString): bool` to determine if a type is an intersection type. * `isDNF(string $typeString): bool` to determine if a type is an intersection type. * `toArray(string $typeString, bool $normalize = true): array` to split a type string to its individual types and optionally normalize the case of the types. * `toArrayUnique(string $typeString, bool $normalize = true): array` to split a type string to its unique individual types and optionally normalize the case of the types. * `filterKeywordTypes(array $types): array` to filter an array with individual types as found in a type string down to only the keyword types. * `filterOOTypes(array $types): array` to filter an array with individual types as found in a type string down to only the OO-name based types. Notes: * The methods in this class have limited or no handling for surrounding or internal whitespace as the type strings these methods are intended to be used with do not contain whitespace. * The behaviour with type strings not retrieved via the `getParameters()`/`getProperties()` methods, or non-type strings, is undefined. This includes type strings found in docblocks. Those are not typically supported by the methods in this class. * The `is*()` methods will **not** check if the type string provided is **_valid_**, as doing so would inhibit what sniffs can flag. The `is*()` methods will only look at the _form_ of the type string to determine if it _could_ be valid for a certain type. Use the {@see \PHPCSUtils\Utils\NamingConventions::isValidIdentifierName()} method if additional validity checks are needed on the individual "types" seen in a type string. And, if needed, use token walking on the tokens of the type to determine whether a type string actually complies with the type rules as set by PHP. Includes extensive unit tests. --- PHPCSUtils/Utils/TypeString.php | 393 +++++++ Tests/Utils/TypeString/FilterTypesTest.php | 489 ++++++++ .../Utils/TypeString/GetKeywordTypesTest.php | 38 + Tests/Utils/TypeString/IsKeywordTest.php | 189 +++ Tests/Utils/TypeString/IsTypeTest.php | 1030 +++++++++++++++++ Tests/Utils/TypeString/NormalizeCaseTest.php | 196 ++++ Tests/Utils/TypeString/ToArrayTest.php | 564 +++++++++ phpstan.neon.dist | 11 +- 8 files changed, 2908 insertions(+), 2 deletions(-) create mode 100644 PHPCSUtils/Utils/TypeString.php create mode 100644 Tests/Utils/TypeString/FilterTypesTest.php create mode 100644 Tests/Utils/TypeString/GetKeywordTypesTest.php create mode 100644 Tests/Utils/TypeString/IsKeywordTest.php create mode 100644 Tests/Utils/TypeString/IsTypeTest.php create mode 100644 Tests/Utils/TypeString/NormalizeCaseTest.php create mode 100644 Tests/Utils/TypeString/ToArrayTest.php 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..430f95e3 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\\.$`" @@ -84,8 +90,9 @@ parameters: # Ignoring as this is fine. - message: '`^Parameter #1 \$exception of method PHPUnit\\Framework\\TestCase::expectException\(\) expects class-string, string given\.$`' - path: Tests/TestUtils/UtilityMethodTestCase/SkipJSCSSTestsOnPHPCS4Test.php - count: 1 + paths: + - Tests/TestUtils/UtilityMethodTestCase/SkipJSCSSTestsOnPHPCS4Test.php + - Tests/Utils/TypeString/FilterTypesTest.php # Level 6 # Test data providers.