From a5f6e96572f6af338da0f46c7eedb50801b5627d Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Wed, 16 Aug 2023 12:23:44 +0200 Subject: [PATCH] misc: replace regex-based type parser with character-based one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduced a complete rewrite of the first layer of the type parser. The previous one would use regex to split a raw type in tokens, but that led to limitations — mostly concerning quoted strings — that are now fixed. Example of previous limitations, now solved: ```php // Union of strings containing space chars (new MapperBuilder()) ->mapper() ->map( "'foo bar'|'baz fiz'", 'baz fiz' ); // Shaped array with special chars in the key (new MapperBuilder()) ->mapper() ->map( "array{'some & key': string}", ['some & key' => 'value'] ); ``` --- .../ReflectionClassDefinitionRepository.php | 7 +- .../Reflection/ReflectionTypeResolver.php | 28 +- .../Factory/LexingTypeParserFactory.php | 4 +- .../Lexer/Token/AdvancedClassNameToken.php | 3 +- src/Type/Parser/LexingParser.php | 106 ++++-- src/Utility/Reflection/DocParser.php | 203 ++++++++++++ src/Utility/Reflection/Reflection.php | 148 --------- .../Object/ScalarValuesMappingTest.php | 32 ++ .../Object/ShapedArrayValuesMappingTest.php | 24 ++ .../Unit/Utility/Reflection/DocParserTest.php | 301 ++++++++++++++++++ .../Utility/Reflection/ReflectionTest.php | 300 ----------------- 11 files changed, 664 insertions(+), 492 deletions(-) create mode 100644 src/Utility/Reflection/DocParser.php create mode 100644 tests/Unit/Utility/Reflection/DocParserTest.php diff --git a/src/Definition/Repository/Reflection/ReflectionClassDefinitionRepository.php b/src/Definition/Repository/Reflection/ReflectionClassDefinitionRepository.php index 5140d865..f77ef8c1 100644 --- a/src/Definition/Repository/Reflection/ReflectionClassDefinitionRepository.php +++ b/src/Definition/Repository/Reflection/ReflectionClassDefinitionRepository.php @@ -15,6 +15,7 @@ use CuyZ\Valinor\Definition\PropertyDefinition; use CuyZ\Valinor\Definition\Repository\AttributesRepository; use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository; +use CuyZ\Valinor\Type\ClassType; use CuyZ\Valinor\Type\GenericType; use CuyZ\Valinor\Type\Parser\Exception\InvalidType; use CuyZ\Valinor\Type\Parser\Factory\Specifications\AliasSpecification; @@ -23,11 +24,11 @@ use CuyZ\Valinor\Type\Parser\Factory\TypeParserFactory; use CuyZ\Valinor\Type\Parser\TypeParser; use CuyZ\Valinor\Type\Type; -use CuyZ\Valinor\Type\ClassType; use CuyZ\Valinor\Type\Types\UnresolvableType; use CuyZ\Valinor\Utility\Reflection\Reflection; use ReflectionMethod; use ReflectionProperty; +use CuyZ\Valinor\Utility\Reflection\DocParser; use function array_filter; use function array_keys; @@ -156,7 +157,7 @@ private function typeResolver(ClassType $type, string $targetClass): ReflectionT private function localTypeAliases(ClassType $type): array { $reflection = Reflection::class($type->className()); - $rawTypes = Reflection::localTypeAliases($reflection); + $rawTypes = DocParser::localTypeAliases($reflection); $typeParser = $this->typeParser($type); @@ -181,7 +182,7 @@ private function localTypeAliases(ClassType $type): array private function importedTypeAliases(ClassType $type): array { $reflection = Reflection::class($type->className()); - $importedTypesRaw = Reflection::importedTypeAliases($reflection); + $importedTypesRaw = DocParser::importedTypeAliases($reflection); $typeParser = $this->typeParser($type); diff --git a/src/Definition/Repository/Reflection/ReflectionTypeResolver.php b/src/Definition/Repository/Reflection/ReflectionTypeResolver.php index ca6a4e1d..b2e53910 100644 --- a/src/Definition/Repository/Reflection/ReflectionTypeResolver.php +++ b/src/Definition/Repository/Reflection/ReflectionTypeResolver.php @@ -10,6 +10,7 @@ use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\MixedType; use CuyZ\Valinor\Type\Types\UnresolvableType; +use CuyZ\Valinor\Utility\Reflection\DocParser; use CuyZ\Valinor\Utility\Reflection\Reflection; use ReflectionFunctionAbstract; use ReflectionParameter; @@ -23,7 +24,7 @@ public function __construct( private TypeParser $advancedParser ) {} - public function resolveType(\ReflectionProperty|\ReflectionParameter|\ReflectionFunctionAbstract $reflection): Type + public function resolveType(ReflectionProperty|ReflectionParameter|ReflectionFunctionAbstract $reflection): Type { $nativeType = $this->nativeType($reflection); $typeFromDocBlock = $this->typeFromDocBlock($reflection); @@ -51,11 +52,24 @@ public function resolveType(\ReflectionProperty|\ReflectionParameter|\Reflection return $typeFromDocBlock; } - private function typeFromDocBlock(\ReflectionProperty|\ReflectionParameter|\ReflectionFunctionAbstract $reflection): ?Type + private function typeFromDocBlock(ReflectionProperty|ReflectionParameter|ReflectionFunctionAbstract $reflection): ?Type { - $type = $reflection instanceof ReflectionFunctionAbstract - ? Reflection::docBlockReturnType($reflection) - : Reflection::docBlockType($reflection); + if ($reflection instanceof ReflectionFunctionAbstract) { + $type = DocParser::functionReturnType($reflection); + } elseif ($reflection instanceof ReflectionProperty) { + $type = DocParser::propertyType($reflection); + } else { + $type = null; + + if ($reflection->isPromoted()) { + // @phpstan-ignore-next-line / parameter is promoted so class exists for sure + $type = DocParser::propertyType($reflection->getDeclaringClass()->getProperty($reflection->name)); + } + + if ($type === null) { + $type = DocParser::parameterType($reflection); + } + } if ($type === null) { return null; @@ -64,7 +78,7 @@ private function typeFromDocBlock(\ReflectionProperty|\ReflectionParameter|\Refl return $this->parseType($type, $reflection, $this->advancedParser); } - private function nativeType(\ReflectionProperty|\ReflectionParameter|\ReflectionFunctionAbstract $reflection): ?Type + private function nativeType(ReflectionProperty|ReflectionParameter|ReflectionFunctionAbstract $reflection): ?Type { $reflectionType = $reflection instanceof ReflectionFunctionAbstract ? $reflection->getReturnType() @@ -83,7 +97,7 @@ private function nativeType(\ReflectionProperty|\ReflectionParameter|\Reflection return $this->parseType($type, $reflection, $this->nativeParser); } - private function parseType(string $raw, \ReflectionProperty|\ReflectionParameter|\ReflectionFunctionAbstract $reflection, TypeParser $parser): Type + private function parseType(string $raw, ReflectionProperty|ReflectionParameter|ReflectionFunctionAbstract $reflection, TypeParser $parser): Type { try { return $parser->parse($raw); diff --git a/src/Type/Parser/Factory/LexingTypeParserFactory.php b/src/Type/Parser/Factory/LexingTypeParserFactory.php index 08d0eea3..1cfb0891 100644 --- a/src/Type/Parser/Factory/LexingTypeParserFactory.php +++ b/src/Type/Parser/Factory/LexingTypeParserFactory.php @@ -39,8 +39,8 @@ private function nativeParser(): TypeParser { $lexer = new NativeLexer(); $lexer = new AdvancedClassLexer($lexer, $this, $this->templateParser); - $lexer = new LexingParser($lexer); + $parser = new LexingParser($lexer); - return new CachedParser($lexer); + return new CachedParser($parser); } } diff --git a/src/Type/Parser/Lexer/Token/AdvancedClassNameToken.php b/src/Type/Parser/Lexer/Token/AdvancedClassNameToken.php index e5bafa96..24107c7f 100644 --- a/src/Type/Parser/Lexer/Token/AdvancedClassNameToken.php +++ b/src/Type/Parser/Lexer/Token/AdvancedClassNameToken.php @@ -31,6 +31,7 @@ use CuyZ\Valinor\Type\Types\ArrayKeyType; use CuyZ\Valinor\Type\ClassType; use CuyZ\Valinor\Type\Types\NativeClassType; +use CuyZ\Valinor\Utility\Reflection\DocParser; use CuyZ\Valinor\Utility\Reflection\Reflection; use ReflectionClass; @@ -182,7 +183,7 @@ private function assignGenerics(string $className, array $templates, array $gene */ private function parentType(ReflectionClass $reflection, ReflectionClass $parentReflection, TypeParser $typeParser): NativeClassType { - $extendedClass = Reflection::extendedClassAnnotation($reflection); + $extendedClass = DocParser::classExtendsTypes($reflection); if (count($extendedClass) > 1) { throw new SeveralExtendTagsFound($reflection); diff --git a/src/Type/Parser/LexingParser.php b/src/Type/Parser/LexingParser.php index 8eee6fcd..324f9b58 100644 --- a/src/Type/Parser/LexingParser.php +++ b/src/Type/Parser/LexingParser.php @@ -1,26 +1,82 @@ splitTokens($raw); + $separators = [' ', '|', '&', '<', '>', '[', ']', '{', '}', ':', '?', ',']; + + $symbols = []; + $current = null; + $quote = null; + + foreach (str_split($raw) as $char) { + if ($quote !== null) { + if ($char === $quote) { + if ($current !== null) { + $symbols[] = $current; + $current = null; + } + + $symbols[] = $char; + $quote = null; + } else { + $current .= $char; + } + + continue; + } + + if ($char === '"' || $char === "'") { + if ($current !== null) { + $symbols[] = $current; + $current = null; + } + + $quote = $char; + $symbols[] = $char; + continue; + } + + if (in_array($char, $separators, true)) { + $count = count($symbols); + + if ($char === ':' && $current === null && $count > 0 && $symbols[$count - 1] === ':') { + $symbols[$count - 1] = '::'; + continue; + } + + if ($current !== null) { + $symbols[] = $current; + } + + $symbols[] = $char; + $current = null; + } else { + $current .= $char; + } + } + + if ($current !== null) { + $symbols[] = $current; + } + + $symbols = $this->detectAnonymousClass($symbols); + $symbols = array_map('trim', $symbols); $symbols = array_filter($symbols, static fn ($value) => $value !== ''); @@ -33,33 +89,21 @@ public function parse(string $raw): Type } /** - * @return string[] - */ - private function splitTokens(string $raw): array - { - if (str_contains($raw, "@anonymous\0")) { - return $this->splitTokensContainingAnonymousClass($raw); - } - - /** @phpstan-ignore-next-line */ - return preg_split('/(::|[\s?|&<>,\[\]{}:\'"])/', $raw, -1, PREG_SPLIT_DELIM_CAPTURE); - } - - /** - * @return string[] + * @param list $symbols + * @return list */ - private function splitTokensContainingAnonymousClass(string $raw): array + private function detectAnonymousClass(array $symbols): array { - /** @var string[] $splits */ - $splits = preg_split('/([a-zA-Z_\x7f-\xff][\\\\\w\x7f-\xff]*+@anonymous\x00.*?\.php(?:0x?|:\d++\$)[\da-fA-F]++)/', $raw, -1, PREG_SPLIT_DELIM_CAPTURE); - $symbols = []; + foreach ($symbols as $key => $symbol) { + if (! str_contains($symbol, "@anonymous\0")) { + continue; + } - foreach ($splits as $symbol) { - if (str_contains($symbol, "@anonymous\0")) { - $symbols[] = $symbol; - } else { - $symbols = [...$symbols, ...$this->splitTokens($symbol)]; + if (count($symbols) >= $key + 3) { + $symbols[$key] = $symbol . $symbols[$key + 1] . $symbols[$key + 2]; } + + array_splice($symbols, $key + 1, 2); } return $symbols; diff --git a/src/Utility/Reflection/DocParser.php b/src/Utility/Reflection/DocParser.php new file mode 100644 index 00000000..0183418c --- /dev/null +++ b/src/Utility/Reflection/DocParser.php @@ -0,0 +1,203 @@ +getDocComment()); + + if ($doc === null) { + return null; + } + + return self::annotationType($doc, 'var'); + } + + public static function parameterType(ReflectionParameter $reflection): ?string + { + $doc = self::sanitizeDocComment($reflection->getDeclaringFunction()->getDocComment()); + + if ($doc === null) { + return null; + } + + if (! preg_match("/(?.*)\\$$reflection->name(\s|\z)/s", $doc, $matches)) { + return null; + } + + return self::annotationType($matches['type'], 'param', false); + } + + public static function functionReturnType(ReflectionFunctionAbstract $reflection): ?string + { + $doc = self::sanitizeDocComment($reflection->getDocComment()); + + if ($doc === null) { + return null; + } + + return self::annotationType($doc, 'return'); + } + + /** + * @param ReflectionClass $reflection + * @return array + */ + public static function localTypeAliases(ReflectionClass $reflection): array + { + $doc = self::sanitizeDocComment($reflection->getDocComment()); + + if ($doc === null) { + return []; + } + + $types = []; + + preg_match_all('/@(phpstan|psalm)-type\s+(?[a-zA-Z]\w*)\s*=?\s*(?.*)/', $doc, $matches); + + foreach ($matches['name'] as $key => $name) { + $types[(string)$name] = self::findType($matches['type'][$key]); + } + + return $types; + } + + /** + * @param ReflectionClass $reflection + * @return array + */ + public static function importedTypeAliases(ReflectionClass $reflection): array + { + $doc = self::sanitizeDocComment($reflection->getDocComment()); + + if ($doc === null) { + return []; + } + + $types = []; + + preg_match_all('/@(phpstan|psalm)-import-type\s+(?[a-zA-Z]\w*)\s*from\s*(?\w+)/', $doc, $matches); + + foreach ($matches['name'] as $key => $name) { + /** @var class-string $class */ + $class = $matches['class'][$key]; + + $types[$class][] = $name; + } + + return $types; + } + + /** + * @param ReflectionClass $reflection + * @return array + */ + public static function classExtendsTypes(ReflectionClass $reflection): array + { + $doc = self::sanitizeDocComment($reflection->getDocComment()); + + if ($doc === null) { + return []; + } + + preg_match_all('/@(phpstan-|psalm-)?extends\s+(?.+)/', $doc, $matches); + + return array_map( + static fn (string $type): string => self::findType($type), + $matches['type'], + ); + } + + private static function annotationType(string $string, string $annotation, bool $left = true): ?string + { + foreach (["@phpstan-$annotation", "@psalm-$annotation", "@$annotation"] as $case) { + $pos = $left + ? strpos($string, $case) + : strrpos($string, $case); + + if ($pos !== false) { + return self::findType(substr($string, $pos + strlen($case))); + } + } + + return null; + } + + private static function findType(string $string): string + { + $string = trim($string); + + $allowed = ['|', '&', '[', ']', '*', ':', '?', '.', ',', '-', '_', '\\']; + $groupMatrix = [ + '{' => '}', + '<' => '>', + '"' => '"', + "'" => "'", + ]; + + $type = ''; + $delimiters = []; + $mustBe = []; + + foreach (str_split($string) as $char) { + if ($mustBe !== [] && $char !== ' ') { + if (! in_array($char, $mustBe, true)) { + break; + } + + $mustBe = []; + } + + if (count($delimiters) > 0 && $char === end($delimiters)) { + array_pop($delimiters); + + if (count($delimiters) === 0) { + $mustBe = ['|', '&']; + } + } elseif (isset($groupMatrix[$char])) { + $delimiters[] = $groupMatrix[$char]; + } + + $type .= $char; + + if ($mustBe === [] + && count($delimiters) === 0 + && ! in_array($char, $allowed, true) + && ! preg_match('/[a-zA-Z0-9]/', $char) + ) { + break; + } + } + + return trim($type); + } + + private static function sanitizeDocComment(string|false $doc): ?string + { + if ($doc === false) { + return null; + } + + $doc = preg_replace('#^\s*/\*\*([^/]+)\*/\s*$#', '$1', $doc); + + return trim(preg_replace('/^\s*\*\s*(\S*)/m', '$1', $doc)); // @phpstan-ignore-line + } +} diff --git a/src/Utility/Reflection/Reflection.php b/src/Utility/Reflection/Reflection.php index 4c1c82d4..307f7f51 100644 --- a/src/Utility/Reflection/Reflection.php +++ b/src/Utility/Reflection/Reflection.php @@ -7,7 +7,6 @@ use Closure; use ReflectionClass; use ReflectionFunction; -use ReflectionFunctionAbstract; use ReflectionIntersectionType; use ReflectionMethod; use ReflectionNamedType; @@ -18,25 +17,15 @@ use Reflector; use RuntimeException; -use function assert; use function class_exists; -use function count; use function implode; use function interface_exists; -use function is_array; -use function preg_match_all; -use function preg_replace; use function spl_object_hash; use function str_contains; -use function trim; /** @internal */ final class Reflection { - private const TOOL_NONE = ''; - private const TOOL_EXPRESSION = '((?psalm|phpstan)-)'; - private const TYPE_EXPRESSION = '(?[\w\s?|&<>\'",-:\\\\\[\]{}*]+)'; - /** @var array> */ private static array $classReflection = []; @@ -133,141 +122,4 @@ public static function flattenType(ReflectionType $type): string return $name; } - - public static function docBlockType(\ReflectionProperty|\ReflectionParameter $reflection): ?string - { - if ($reflection instanceof ReflectionProperty) { - return self::parseDocBlock( - self::sanitizeDocComment($reflection), - sprintf('@%s?var\s+%s', self::TOOL_EXPRESSION, self::TYPE_EXPRESSION) - ); - } - - if ($reflection->isPromoted()) { - $type = self::parseDocBlock( - // @phpstan-ignore-next-line / parameter is promoted so class exists for sure - self::sanitizeDocComment($reflection->getDeclaringClass()->getProperty($reflection->name)), - sprintf('@%s?var\s+%s', self::TOOL_EXPRESSION, self::TYPE_EXPRESSION) - ); - - if ($type !== null) { - return $type; - } - } - - return self::parseDocBlock( - self::sanitizeDocComment($reflection->getDeclaringFunction()), - sprintf('@%s?param\s+%s\s+\$\b%s\b', self::TOOL_EXPRESSION, self::TYPE_EXPRESSION, $reflection->name) - ); - } - - private static function parseDocBlock(string $docComment, string $expression): ?string - { - if (! preg_match_all("/$expression/", $docComment, $matches)) { - return null; - } - - foreach ($matches['tool'] as $index => $tool) { - if ($tool === self::TOOL_NONE) { - continue; - } - - return trim($matches['type'][$index]); - } - - return trim($matches['type'][0]); - } - - public static function docBlockReturnType(ReflectionFunctionAbstract $reflection): ?string - { - $docComment = self::sanitizeDocComment($reflection); - - $expression = sprintf('/@%s?return\s+%s/', self::TOOL_EXPRESSION, self::TYPE_EXPRESSION); - - if (! preg_match_all($expression, $docComment, $matches)) { - return null; - } - - foreach ($matches['tool'] as $index => $tool) { - if ($tool === self::TOOL_NONE) { - continue; - } - - return trim($matches['type'][$index]); - } - - return trim($matches['type'][0]); - } - - /** - * @param ReflectionClass $reflection - * @return array - */ - public static function localTypeAliases(ReflectionClass $reflection): array - { - $types = []; - $docComment = self::sanitizeDocComment($reflection); - - $expression = sprintf('/@(phpstan|psalm)-type\s+([a-zA-Z]\w*)\s*=?\s*%s/', self::TYPE_EXPRESSION); - - preg_match_all($expression, $docComment, $matches); - - foreach ($matches[2] as $key => $name) { - $types[(string)$name] = $matches[3][$key]; - } - - return $types; - } - - /** - * @param ReflectionClass $reflection - * @return array - */ - public static function importedTypeAliases(ReflectionClass $reflection): array - { - $types = []; - $docComment = self::sanitizeDocComment($reflection); - - $expression = sprintf('/@(phpstan|psalm)-import-type\s+([a-zA-Z]\w*)\s*from\s*%s/', self::TYPE_EXPRESSION); - preg_match_all($expression, $docComment, $matches); - - foreach ($matches[2] as $key => $name) { - /** @var class-string $classString */ - $classString = $matches[3][$key]; - - $types[$classString][] = $name; - } - - return $types; - } - - /** - * @param ReflectionClass $reflection - * @return array - */ - public static function extendedClassAnnotation(ReflectionClass $reflection): array - { - $docComment = self::sanitizeDocComment($reflection); - - $expression = sprintf('/@%s?extends\s+%s/', self::TOOL_EXPRESSION, self::TYPE_EXPRESSION); - preg_match_all($expression, $docComment, $matches); - - assert(is_array($matches['type'])); - - if (count($matches['type']) === 0) { - return []; - } - - return $matches['type']; - } - - /** - * @param ReflectionClass|ReflectionProperty|ReflectionFunctionAbstract $reflection - */ - private static function sanitizeDocComment(\ReflectionClass|\ReflectionProperty|ReflectionFunctionAbstract $reflection): string - { - $docComment = preg_replace('#^\s*/\*\*([^/]+)\*/\s*$#', '$1', $reflection->getDocComment() ?: ''); - - return trim(preg_replace('/^\s*\*\s*(\S*)/m', '$1', $docComment)); // @phpstan-ignore-line - } } diff --git a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php index c7c09317..1d209e17 100644 --- a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php @@ -36,7 +36,11 @@ public function test_values_are_mapped_properly(): void 'nonEmptyString' => 'bar', 'numericString' => '1337', 'stringValueWithSingleQuote' => 'baz', + 'stringValueContainingSpaceWithSingleQuote' => 'baz baz', + 'stringValueContainingSpecialCharsWithSingleQuote' => 'baz & $ § % baz', 'stringValueWithDoubleQuote' => 'fiz', + 'stringValueContainingSpaceWithDoubleQuote' => 'fiz fiz', + 'stringValueContainingSpecialCharsWithDoubleQuote' => 'fiz & $ § % fiz', 'classString' => self::class, 'classStringOfDateTime' => DateTimeImmutable::class, 'classStringOfAlias' => stdClass::class, @@ -66,7 +70,11 @@ public function test_values_are_mapped_properly(): void self::assertSame('bar', $result->nonEmptyString); self::assertSame('1337', $result->numericString); self::assertSame('baz', $result->stringValueWithSingleQuote); // @phpstan-ignore-line + self::assertSame('baz baz', $result->stringValueContainingSpaceWithSingleQuote); // @phpstan-ignore-line + self::assertSame('baz & $ § % baz', $result->stringValueContainingSpecialCharsWithSingleQuote); // @phpstan-ignore-line self::assertSame('fiz', $result->stringValueWithDoubleQuote); // @phpstan-ignore-line + self::assertSame('fiz fiz', $result->stringValueContainingSpaceWithDoubleQuote); // @phpstan-ignore-line + self::assertSame('fiz & $ § % fiz', $result->stringValueContainingSpecialCharsWithDoubleQuote); // @phpstan-ignore-line self::assertSame(self::class, $result->classString); self::assertSame(DateTimeImmutable::class, $result->classStringOfDateTime); self::assertSame(stdClass::class, $result->classStringOfAlias); @@ -133,9 +141,21 @@ class ScalarValues /** @var 'baz' */ public string $stringValueWithSingleQuote; + /** @var 'baz baz' */ + public string $stringValueContainingSpaceWithSingleQuote; + + /** @var 'baz & $ § % baz' */ + public string $stringValueContainingSpecialCharsWithSingleQuote; + /** @var "fiz" */ public string $stringValueWithDoubleQuote; + /** @var "fiz fiz" */ + public string $stringValueContainingSpaceWithDoubleQuote; + + /** @var "fiz & $ § % fiz" */ + public string $stringValueContainingSpecialCharsWithDoubleQuote; + /** @var class-string */ public string $classString = stdClass::class; @@ -161,7 +181,11 @@ class ScalarValuesWithConstructor extends ScalarValues * @param non-empty-string $nonEmptyString * @param numeric-string $numericString * @param 'baz' $stringValueWithSingleQuote + * @param 'baz baz' $stringValueContainingSpaceWithSingleQuote + * @param 'baz & $ § % baz' $stringValueContainingSpecialCharsWithSingleQuote * @param "fiz" $stringValueWithDoubleQuote + * @param "fiz fiz" $stringValueContainingSpaceWithDoubleQuote + * @param "fiz & $ § % fiz" $stringValueContainingSpecialCharsWithDoubleQuote * @param class-string $classString * @param class-string $classStringOfDateTime * @param class-string $classStringOfAlias @@ -184,7 +208,11 @@ public function __construct( string $nonEmptyString, string $numericString, string $stringValueWithSingleQuote, + string $stringValueContainingSpaceWithSingleQuote, + string $stringValueContainingSpecialCharsWithSingleQuote, string $stringValueWithDoubleQuote, + string $stringValueContainingSpaceWithDoubleQuote, + string $stringValueContainingSpecialCharsWithDoubleQuote, string $classString, string $classStringOfDateTime, string $classStringOfAlias @@ -206,7 +234,11 @@ public function __construct( $this->nonEmptyString = $nonEmptyString; $this->numericString = $numericString; $this->stringValueWithSingleQuote = $stringValueWithSingleQuote; + $this->stringValueContainingSpaceWithSingleQuote = $stringValueContainingSpaceWithSingleQuote; + $this->stringValueContainingSpecialCharsWithSingleQuote = $stringValueContainingSpecialCharsWithSingleQuote; $this->stringValueWithDoubleQuote = $stringValueWithDoubleQuote; + $this->stringValueContainingSpaceWithDoubleQuote = $stringValueContainingSpaceWithDoubleQuote; + $this->stringValueContainingSpecialCharsWithDoubleQuote = $stringValueContainingSpecialCharsWithDoubleQuote; $this->classString = $classString; $this->classStringOfDateTime = $classStringOfDateTime; $this->classStringOfAlias = $classStringOfAlias; diff --git a/tests/Integration/Mapping/Object/ShapedArrayValuesMappingTest.php b/tests/Integration/Mapping/Object/ShapedArrayValuesMappingTest.php index 9cad0db9..72756b58 100644 --- a/tests/Integration/Mapping/Object/ShapedArrayValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ShapedArrayValuesMappingTest.php @@ -19,6 +19,16 @@ public function test_values_are_mapped_properly(): void 'foo' => 'fiz', 'bar' => 42, ], + 'basicShapedArrayWithSingleQuotedStringKeys' => [ + 'foo' => 'fiz', + 'bar fiz' => 42, + 'fiz & $ § % fiz' => 42.404, + ], + 'basicShapedArrayWithDoubleQuotedStringKeys' => [ + 'foo' => 'fiz', + 'bar fiz' => 42, + 'fiz & $ § % fiz' => 42.404, + ], 'basicShapedArrayWithIntegerKeys' => [ 0 => 'fiz', 1 => 42.404, @@ -62,6 +72,8 @@ public function test_values_are_mapped_properly(): void } self::assertSame($source['basicShapedArrayWithStringKeys'], $result->basicShapedArrayWithStringKeys); + self::assertSame($source['basicShapedArrayWithSingleQuotedStringKeys'], $result->basicShapedArrayWithSingleQuotedStringKeys); + self::assertSame($source['basicShapedArrayWithDoubleQuotedStringKeys'], $result->basicShapedArrayWithDoubleQuotedStringKeys); self::assertSame($source['basicShapedArrayWithIntegerKeys'], $result->basicShapedArrayWithIntegerKeys); self::assertInstanceOf(SimpleObject::class, $result->shapedArrayWithObject['foo']); // @phpstan-ignore-line self::assertSame($source['shapedArrayWithOptionalValue'], $result->shapedArrayWithOptionalValue); @@ -99,6 +111,12 @@ class ShapedArrayValues /** @var array{foo: string, bar: int} */ public array $basicShapedArrayWithStringKeys; + /** @var array{'foo': string, 'bar fiz': int, 'fiz & $ § % fiz': float} */ + public array $basicShapedArrayWithSingleQuotedStringKeys; + + /** @var array{"foo": string, "bar fiz": int, "fiz & $ § % fiz": float} */ + public array $basicShapedArrayWithDoubleQuotedStringKeys; + /** @var array{0: string, 1: float} */ public array $basicShapedArrayWithIntegerKeys; @@ -144,6 +162,8 @@ class ShapedArrayValuesWithConstructor extends ShapedArrayValues { /** * @param array{foo: string, bar: int} $basicShapedArrayWithStringKeys + * @param array{'foo': string, 'bar fiz': int, 'fiz & $ § % fiz': float} $basicShapedArrayWithSingleQuotedStringKeys + * @param array{"foo": string, "bar fiz": int, "fiz & $ § % fiz": float} $basicShapedArrayWithDoubleQuotedStringKeys * @param array{0: string, 1: float} $basicShapedArrayWithIntegerKeys * @param array{foo: SimpleObject} $shapedArrayWithObject * @param array{optionalString?: string} $shapedArrayWithOptionalValue @@ -163,6 +183,8 @@ class ShapedArrayValuesWithConstructor extends ShapedArrayValues */ public function __construct( array $basicShapedArrayWithStringKeys, + array $basicShapedArrayWithSingleQuotedStringKeys, + array $basicShapedArrayWithDoubleQuotedStringKeys, array $basicShapedArrayWithIntegerKeys, array $shapedArrayWithObject, array $shapedArrayWithOptionalValue, @@ -175,6 +197,8 @@ public function __construct( array $shapedArrayWithLowercaseEnumNameAsKey, ) { $this->basicShapedArrayWithStringKeys = $basicShapedArrayWithStringKeys; + $this->basicShapedArrayWithSingleQuotedStringKeys = $basicShapedArrayWithSingleQuotedStringKeys; + $this->basicShapedArrayWithDoubleQuotedStringKeys = $basicShapedArrayWithDoubleQuotedStringKeys; $this->basicShapedArrayWithIntegerKeys = $basicShapedArrayWithIntegerKeys; $this->shapedArrayWithObject = $shapedArrayWithObject; $this->shapedArrayWithOptionalValue = $shapedArrayWithOptionalValue; diff --git a/tests/Unit/Utility/Reflection/DocParserTest.php b/tests/Unit/Utility/Reflection/DocParserTest.php new file mode 100644 index 00000000..9448070d --- /dev/null +++ b/tests/Unit/Utility/Reflection/DocParserTest.php @@ -0,0 +1,301 @@ + + */ + public function callables_with_docblock_typed_return_type(): iterable + { + yield 'phpdoc' => [ + /** @return int */ + fn () => 42, + 'int', + ]; + + yield 'phpdoc followed by new line' => [ + /** + * @return int + * + */ + fn () => 42, + 'int', + ]; + + yield 'phpdoc literal string' => [ + /** @return 'foo' */ + fn () => 'foo', + '\'foo\'', + ]; + + yield 'phpdoc const with joker' => [ + /** @return ObjectWithConstants::CONST_WITH_* */ + fn (): string => ObjectWithConstants::CONST_WITH_STRING_VALUE_A, + 'ObjectWithConstants::CONST_WITH_*', + ]; + + if (PHP_VERSION_ID >= 8_01_00) { + yield 'phpdoc enum with joker' => [ + /** @return BackedStringEnum::BA* */ + fn () => BackedStringEnum::BAR, + 'BackedStringEnum::BA*', + ]; + } + + yield 'psalm' => [ + /** @psalm-return int */ + fn () => 42, + 'int', + ]; + + yield 'psalm trailing' => [ + /** + * @return int + * @psalm-return positive-int + */ + fn () => 42, + 'positive-int', + ]; + + yield 'psalm leading' => [ + /** + * @psalm-return positive-int + * @return int + */ + fn () => 42, + 'positive-int', + ]; + + yield 'phpstan' => [ + /** @phpstan-return int */ + fn () => 42, + 'int', + ]; + + yield 'phpstan trailing' => [ + /** + * @return int + * @phpstan-return positive-int + */ + fn () => 42, + 'positive-int', + ]; + + yield 'phpstan leading' => [ + /** + * @phpstan-return positive-int + * @return int + */ + fn () => 42, + 'positive-int', + ]; + } + + public function test_docblock_return_type_with_no_docblock_returns_null(): void + { + $callable = static function (): void {}; + + $type = DocParser::functionReturnType(new ReflectionFunction($callable)); + + self::assertNull($type); + } + + /** + * @param non-empty-string $expectedType + * @dataProvider objects_with_docblock_typed_properties + */ + public function test_docblock_var_type_is_fetched_correctly( + ReflectionParameter|ReflectionProperty $reflection, + string $expectedType + ): void { + $type = $reflection instanceof ReflectionProperty + ? DocParser::propertyType($reflection) + : DocParser::parameterType($reflection); + + self::assertEquals($expectedType, $type); + } + + /** + * @return iterable + */ + public function objects_with_docblock_typed_properties(): iterable + { + yield 'phpdoc @var' => [ + new ReflectionProperty(new class () { + /** @var string */ + public $foo; + }, 'foo'), + 'string', + ]; + + yield 'phpdoc @var followed by new line' => [ + new ReflectionProperty(new class () { + /** + * @var string + * + */ + public $foo; + }, 'foo'), + 'string', + ]; + + yield 'psalm @var standalone' => [ + new ReflectionProperty(new class () { + /** @psalm-var string */ + public $foo; + }, 'foo'), + 'string', + ]; + + yield 'psalm @var leading' => [ + new ReflectionProperty(new class () { + /** + * @psalm-var non-empty-string + * @var string + */ + public $foo; + }, 'foo'), + 'non-empty-string', + ]; + + yield 'psalm @var trailing' => [ + new ReflectionProperty(new class () { + /** + * @var string + * @psalm-var non-empty-string + */ + public $foo; + }, 'foo'), + 'non-empty-string', + ]; + + yield 'phpstan @var standalone' => [ + new ReflectionProperty(new class () { + /** @phpstan-var string */ + public $foo; + }, 'foo'), + 'string', + ]; + + yield 'phpstan @var leading' => [ + new ReflectionProperty(new class () { + /** + * @phpstan-var non-empty-string + * @var string + */ + public $foo; + }, 'foo'), + 'non-empty-string', + ]; + + yield 'phpstan @var trailing' => [ + new ReflectionProperty(new class () { + /** + * @var string + * @phpstan-var non-empty-string + */ + public $foo; + }, 'foo'), + 'non-empty-string', + ]; + + yield 'phpdoc @param' => [ + new ReflectionParameter( + /** @param string $string */ + static function ($string): void {}, + 'string', + ), + 'string', + ]; + + yield 'psalm @param standalone' => [ + new ReflectionParameter( + /** @psalm-param string $string */ + static function ($string): void {}, + 'string', + ), + 'string', + ]; + + yield 'psalm @param leading' => [ + new ReflectionParameter( + /** + * @psalm-param non-empty-string $string + * @param string $string + */ + static function ($string): void {}, + 'string', + ), + 'non-empty-string', + ]; + + yield 'psalm @param trailing' => [ + new ReflectionParameter( + /** + * @param string $string + * @psalm-param non-empty-string $string + */ + static function ($string): void {}, + 'string', + ), + 'non-empty-string', + ]; + + yield 'phpstan @param standalone' => [ + new ReflectionParameter( + /** @phpstan-param string $string */ + static function ($string): void {}, + 'string', + ), + 'string', + ]; + + yield 'phpstan @param leading' => [ + new ReflectionParameter( + /** + * @phpstan-param non-empty-string $string + * @param string $string + */ + static function ($string): void {}, + 'string', + ), + 'non-empty-string', + ]; + + yield 'phpstan @param trailing' => [ + new ReflectionParameter( + /** + * @param string $string + * @phpstan-param non-empty-string $string + */ + static function ($string): void {}, + 'string', + ), + 'non-empty-string', + ]; + } +} diff --git a/tests/Unit/Utility/Reflection/ReflectionTest.php b/tests/Unit/Utility/Reflection/ReflectionTest.php index 81a2affc..494dd1c5 100644 --- a/tests/Unit/Utility/Reflection/ReflectionTest.php +++ b/tests/Unit/Utility/Reflection/ReflectionTest.php @@ -5,8 +5,6 @@ namespace CuyZ\Valinor\Tests\Unit\Utility\Reflection; use Closure; -use CuyZ\Valinor\Tests\Fixture\Enum\BackedStringEnum; -use CuyZ\Valinor\Tests\Fixture\Object\ObjectWithConstants; use CuyZ\Valinor\Tests\Fixture\Object\ObjectWithPropertyWithNativeDisjunctiveNormalFormType; use CuyZ\Valinor\Tests\Fixture\Object\ObjectWithPropertyWithNativeIntersectionType; use CuyZ\Valinor\Tests\Fixture\Object\ObjectWithPropertyWithNativePhp82StandaloneTypes; @@ -14,8 +12,6 @@ use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionFunction; -use ReflectionMethod; -use ReflectionParameter; use ReflectionProperty; use ReflectionType; use stdClass; @@ -189,302 +185,6 @@ public function test_native_false_type_is_handled(): void self::assertSame('false', Reflection::flattenType($type)); } - - /** - * @param non-empty-string $expectedType - * @dataProvider callables_with_docblock_typed_return_type - */ - public function test_docblock_return_type_is_fetched_correctly( - callable $dockblockTypedCallable, - string $expectedType - ): void { - $type = Reflection::docBlockReturnType(new ReflectionFunction(Closure::fromCallable($dockblockTypedCallable))); - - self::assertSame($expectedType, $type); - } - - public function test_docblock_return_type_with_no_docblock_returns_null(): void - { - $callable = static function (): void {}; - - $type = Reflection::docBlockReturnType(new ReflectionFunction($callable)); - - self::assertNull($type); - } - - /** - * @param non-empty-string $expectedType - * @dataProvider objects_with_docblock_typed_properties - */ - public function test_docblock_var_type_is_fetched_correctly( - \ReflectionParameter|\ReflectionProperty $property, - string $expectedType - ): void { - self::assertEquals($expectedType, Reflection::docBlockType($property)); - } - - public function test_docblock_var_type_is_fetched_correctly_with_property_promotion(): void - { - $class = new class ('foo') { - public function __construct( - /** @var non-empty-string */ - public string $someProperty - ) {} - }; - - $type = Reflection::docBlockType((new ReflectionMethod($class, '__construct'))->getParameters()[0]); - - self::assertEquals('non-empty-string', $type); - } - - /** - * @return iterable - */ - public function callables_with_docblock_typed_return_type(): iterable - { - yield 'phpdoc' => [ - /** @return int */ - fn () => 42, - 'int', - ]; - - yield 'phpdoc followed by new line' => [ - /** - * @return int - * - */ - fn () => 42, - 'int', - ]; - - yield 'phpdoc literal string' => [ - /** @return 'foo' */ - fn () => 'foo', - '\'foo\'', - ]; - - yield 'phpdoc const with joker' => [ - /** @return ObjectWithConstants::CONST_WITH_* */ - fn (): string => ObjectWithConstants::CONST_WITH_STRING_VALUE_A, - 'ObjectWithConstants::CONST_WITH_*', - ]; - - if (PHP_VERSION_ID >= 8_01_00) { - yield 'phpdoc enum with joker' => [ - /** @return BackedStringEnum::BA* */ - fn () => BackedStringEnum::BAR, - 'BackedStringEnum::BA*', - ]; - } - - yield 'psalm' => [ - /** @psalm-return int */ - fn () => 42, - 'int', - ]; - - yield 'psalm trailing' => [ - /** - * @return int - * @psalm-return positive-int - */ - fn () => 42, - 'positive-int', - ]; - - yield 'psalm leading' => [ - /** - * @psalm-return positive-int - * @return int - */ - fn () => 42, - 'positive-int', - ]; - - yield 'phpstan' => [ - /** @phpstan-return int */ - fn () => 42, - 'int', - ]; - - yield 'phpstan trailing' => [ - /** - * @return int - * @phpstan-return positive-int - */ - fn () => 42, - 'positive-int', - ]; - - yield 'phpstan leading' => [ - /** - * @phpstan-return positive-int - * @return int - */ - fn () => 42, - 'positive-int', - ]; - } - - /** - * @return iterable - */ - public function objects_with_docblock_typed_properties(): iterable - { - yield 'phpdoc @var' => [ - new ReflectionProperty(new class () { - /** @var string */ - public $foo; - }, 'foo'), - 'string', - ]; - - yield 'phpdoc @var followed by new line' => [ - new ReflectionProperty(new class () { - /** - * @var string - * - */ - public $foo; - }, 'foo'), - 'string', - ]; - - yield 'psalm @var standalone' => [ - new ReflectionProperty(new class () { - /** @psalm-var string */ - public $foo; - }, 'foo'), - 'string', - ]; - - yield 'psalm @var leading' => [ - new ReflectionProperty(new class () { - /** - * @psalm-var non-empty-string - * @var string - */ - public $foo; - }, 'foo'), - 'non-empty-string', - ]; - - yield 'psalm @var trailing' => [ - new ReflectionProperty(new class () { - /** - * @var string - * @psalm-var non-empty-string - */ - public $foo; - }, 'foo'), - 'non-empty-string', - ]; - - yield 'phpstan @var standalone' => [ - new ReflectionProperty(new class () { - /** @phpstan-var string */ - public $foo; - }, 'foo'), - 'string', - ]; - - yield 'phpstan @var leading' => [ - new ReflectionProperty(new class () { - /** - * @phpstan-var non-empty-string - * @var string - */ - public $foo; - }, 'foo'), - 'non-empty-string', - ]; - - yield 'phpstan @var trailing' => [ - new ReflectionProperty(new class () { - /** - * @var string - * @phpstan-var non-empty-string - */ - public $foo; - }, 'foo'), - 'non-empty-string', - ]; - - yield 'phpdoc @param' => [ - new ReflectionParameter( - /** @param string $string */ - static function ($string): void {}, - 'string', - ), - 'string', - ]; - - yield 'psalm @param standalone' => [ - new ReflectionParameter( - /** @psalm-param string $string */ - static function ($string): void {}, - 'string', - ), - 'string', - ]; - - yield 'psalm @param leading' => [ - new ReflectionParameter( - /** - * @psalm-param non-empty-string $string - * @param string $string - */ - static function ($string): void {}, - 'string', - ), - 'non-empty-string', - ]; - - yield 'psalm @param trailing' => [ - new ReflectionParameter( - /** - * @param string $string - * @psalm-param non-empty-string $string - */ - static function ($string): void {}, - 'string', - ), - 'non-empty-string', - ]; - - yield 'phpstan @param standalone' => [ - new ReflectionParameter( - /** @phpstan-param string $string */ - static function ($string): void {}, - 'string', - ), - 'string', - ]; - - yield 'phpstan @param leading' => [ - new ReflectionParameter( - /** - * @phpstan-param non-empty-string $string - * @param string $string - */ - static function ($string): void {}, - 'string', - ), - 'non-empty-string', - ]; - - yield 'phpstan @param trailing' => [ - new ReflectionParameter( - /** - * @param string $string - * @phpstan-param non-empty-string $string - */ - static function ($string): void {}, - 'string', - ), - 'non-empty-string', - ]; - } } function some_function(): void {}