diff --git a/SymfonyCustom/Sniffs/NamingConventions/ValidTypeHintSniff.php b/SymfonyCustom/Sniffs/NamingConventions/ValidTypeHintSniff.php index 01b191f..7983f42 100644 --- a/SymfonyCustom/Sniffs/NamingConventions/ValidTypeHintSniff.php +++ b/SymfonyCustom/Sniffs/NamingConventions/ValidTypeHintSniff.php @@ -4,9 +4,9 @@ namespace SymfonyCustom\Sniffs\NamingConventions; +use PHP_CodeSniffer\Exceptions\DeepExitException; use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Sniffs\Sniff; -use PHP_CodeSniffer\Util\Common; use SymfonyCustom\Helpers\SniffHelper; /** @@ -14,44 +14,102 @@ */ class ValidTypeHintSniff implements Sniff { - private const TEXT = '[\\\\a-z0-9]'; - private const OPENER = '\<|\[|\{|\('; - private const MIDDLE = '\,|\:|\=\>'; - private const CLOSER = '\>|\]|\}|\)'; - private const SEPARATOR = '\&|\|'; - - /* + /** * is any non-array, non-generic, non-alternated type, eg `int` or `\Foo` * is array of , eg `int[]` or `\Foo[]` - * is generic collection type, like `array`, `Collection` and more complex like `Collection>` - * is , or type, like `int`, `bool[]` or `Collection` + * is generic collection type, like `array`, `Collection` or more complex` + * is array key => value type, like `array{type: string, name: string, value: mixed}` + * is , , , type * is one or more types alternated via `|`, like `int|bool[]|Collection` */ private const REGEX_TYPES = ' (? (? (? - (?&simple)(\[\])* - ) - | - (? - [@$?]?[\\\\\w]+ + (?¬Array)(?: + \s*\[\s*\] + )+ ) | - (? - (?(?&simple)) - < - (?:(?(?&types)),\s*)?(?(?&types)|(?&generic)) - > + (? + (? + \(\s*(? + (?&types) + )\s*\) + ) + | + (? + (? + (?&simple) + ) + \s*<\s* + (? + (?:(?&types)\s*,\s*)* + (?&types) + ) + \s*> + ) + | + (? + array\s*{\s* + (? + (?: + (? + (?:\w+\s*\??:\s*)? + (?&types) + ) + \s*,\s* + )* + (?&objectKeyValue) + ) + \s*} + ) + | + (? + \\\\?\w+(?:\\\\\w+)* + | + \$this + ) ) ) (?: - \| - (?:(?&simple)|(?&array)|(?&generic)) + \s*[\|&]\s*(?&type) )* ) '; + /** + * False if the type is not a reserved keyword and the check can't be case insensitive + **/ + private const TYPES = [ + 'array' => true, + 'bool' => true, + 'callable' => true, + 'false' => true, + 'float' => true, + 'int' => true, + 'iterable' => true, + 'mixed' => false, + 'null' => true, + 'number' => false, + 'object' => true, + 'resource' => false, + 'self' => true, + 'static' => true, + 'string' => true, + 'true' => true, + 'void' => true, + '$this' => true, + ]; + + private const ALIAS_TYPES = [ + 'boolean' => 'bool', + 'integer' => 'int', + 'double' => 'float', + 'real' => 'float', + 'callback' => 'callable', + ]; + /** * @return array */ @@ -68,29 +126,41 @@ public function process(File $phpcsFile, $stackPtr): void { $tokens = $phpcsFile->getTokens(); - if (in_array($tokens[$stackPtr]['content'], SniffHelper::TAGS_WITH_TYPE)) { - $matchingResult = preg_match( - '{^'.self::REGEX_TYPES.'(?:[ \t].*)?$}sx', - $tokens[$stackPtr + 2]['content'], - $matches - ); + if (!in_array($tokens[$stackPtr]['content'], SniffHelper::TAGS_WITH_TYPE)) { + return; + } - $content = 1 === $matchingResult ? $matches['types'] : ''; - $endOfContent = preg_replace('/'.preg_quote($content, '/').'/', '', $tokens[$stackPtr + 2]['content'], 1); + $matchingResult = preg_match( + '{^'.self::REGEX_TYPES.'(?:[\s\t].*)?$}six', + $tokens[$stackPtr + 2]['content'], + $matches + ); + $content = 1 === $matchingResult ? $matches['types'] : ''; + $endOfContent = substr($tokens[$stackPtr + 2]['content'], strlen($content)); + + try { $suggestedType = $this->getValidTypes($content); + } catch (DeepExitException $exception) { + $phpcsFile->addError( + $exception->getMessage(), + $stackPtr + 2, + 'Exception' + ); - if ($content !== $suggestedType) { - $fix = $phpcsFile->addFixableError( - 'For type-hinting in PHPDocs, use %s instead of %s', - $stackPtr + 2, - 'Invalid', - [$suggestedType, $content] - ); + return; + } + + if ($content !== $suggestedType) { + $fix = $phpcsFile->addFixableError( + 'For type-hinting in PHPDocs, use %s instead of %s', + $stackPtr + 2, + 'Invalid', + [$suggestedType, $content] + ); - if ($fix) { - $phpcsFile->fixer->replaceToken($stackPtr + 2, $suggestedType.$endOfContent); - } + if ($fix) { + $phpcsFile->fixer->replaceToken($stackPtr + 2, $suggestedType.$endOfContent); } } } @@ -98,50 +168,124 @@ public function process(File $phpcsFile, $stackPtr): void /** * @param string $content * - * @return array + * @return string + * + * @throws DeepExitException */ - private function getTypes(string $content): array + private function getValidTypes(string $content): string { + $content = preg_replace('/\s/', '', $content); + $types = []; + $separators = []; while ('' !== $content && false !== $content) { - preg_match('{^'.self::REGEX_TYPES.'$}x', $content, $matches); + preg_match('{^'.self::REGEX_TYPES.'$}ix', $content, $matches); + + if (isset($matches['array']) && '' !== $matches['array']) { + $validType = $this->getValidTypes(substr($matches['array'], 0, -2)).'[]'; + } elseif (isset($matches['multiple']) && '' !== $matches['multiple']) { + $validType = '('.$this->getValidTypes($matches['mutipleContent']).')'; + } elseif (isset($matches['generic']) && '' !== $matches['generic']) { + $validType = $this->getValidGenericType($matches['genericName'], $matches['genericContent']); + } elseif (isset($matches['object']) && '' !== $matches['object']) { + $validType = $this->getValidObjectType($matches['objectContent']); + } else { + $validType = $this->getValidType($matches['type']); + } - $types[] = $matches['type']; + $types[] = $validType; + + $separators[] = substr($content, strlen($matches['type']), 1); $content = substr($content, strlen($matches['type']) + 1); } + // Remove last separator since it's an empty string + array_pop($separators); + + $uniqueSeparators = array_unique($separators); + switch (count($uniqueSeparators)) { + case 0: + return implode('', $types); + case 1: + return implode($uniqueSeparators[0], $this->orderTypes($types)); + default: + throw new DeepExitException( + 'Union and intersection types must be grouped with parenthesis when used in the same expression' + ); + } + } + + /** + * @param array $types + * + * @return array + */ + private function orderTypes(array $types): array + { + $types = array_unique($types); + usort($types, function ($type1, $type2) { + if ('null' === $type1) { + return 1; + } + + if ('null' === $type2) { + return -1; + } + + return 0; + }); + return $types; } /** - * @param string $content + * @param string $genericName + * @param string $genericContent * * @return string + * + * @throws DeepExitException */ - private function getValidTypes(string $content): string + private function getValidGenericType(string $genericName, string $genericContent): string { - $types = $this->getTypes($content); + $validType = $this->getValidType($genericName).'<'; - foreach ($types as $index => $type) { - $type = str_replace(' ', '', $type); + while ('' !== $genericContent && false !== $genericContent) { + preg_match('{^'.self::REGEX_TYPES.',?}ix', $genericContent, $matches); - preg_match('{^'.self::REGEX_TYPES.'$}x', $type, $matches); - if (isset($matches['generic'])) { - $validType = $this->getValidType($matches['genericName']).'<'; + $validType .= $this->getValidTypes($matches['types']).', '; + $genericContent = substr($genericContent, strlen($matches['types']) + 1); + } - if ('' !== $matches['genericKey']) { - $validType .= $this->getValidTypes($matches['genericKey']).', '; - } + return preg_replace('/,\s$/', '>', $validType); + } - $validType .= $this->getValidTypes($matches['genericValue']).'>'; - } else { - $validType = $this->getValidType($type); + /** + * @param string $objectContent + * + * @return string + * + * @throws DeepExitException + */ + private function getValidObjectType(string $objectContent): string + { + $validType = 'array{'; + + while ('' !== $objectContent && false !== $objectContent) { + $split = preg_split('/(\??:|,)/', $objectContent, 2, PREG_SPLIT_DELIM_CAPTURE); + + if (isset($split[1]) && ',' !== $split[1]) { + $validType .= $split[0].$split[1].' '; + $objectContent = $split[2]; } - $types[$index] = $validType; + preg_match('{^'.self::REGEX_TYPES.',?}ix', $objectContent, $matches); + + $validType .= $this->getValidTypes($matches['types']).', '; + $objectContent = substr($objectContent, strlen($matches['types']) + 1); } - return implode('|', $types); + return preg_replace('/,\s$/', '}', $validType); } /** @@ -151,20 +295,16 @@ private function getValidTypes(string $content): string */ private function getValidType(string $typeName): string { - if ('[]' === substr($typeName, -2)) { - return $this->getValidType(substr($typeName, 0, -2)).'[]'; + $lowerType = strtolower($typeName); + if (isset(self::TYPES[$lowerType])) { + return self::TYPES[$lowerType] ? $lowerType : $typeName; } - $lowerType = strtolower($typeName); - switch ($lowerType) { - case 'bool': - case 'boolean': - return 'bool'; - case 'int': - case 'integer': - return 'int'; + // This can't be case insensitive since this is not reserved keyword + if (isset(self::ALIAS_TYPES[$typeName])) { + return self::ALIAS_TYPES[$typeName]; } - return Common::suggestType($typeName); + return $typeName; } } diff --git a/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.inc b/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.inc index 4dd4b3c..33393be 100644 --- a/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.inc +++ b/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.inc @@ -1,47 +1,68 @@ |integer[]|array truc */ -/** @method array|integer[]|array> truc */ +/** + * Array, generic and object types + * + * @param array|integer[]|array foo + * @param Generator bar + * @param Generator, array, array, array> baz + * @param array[]|boolean + * @param array{0: integer, 1?: integer} + * @param array{integer, integer} + * @param array{foo: integer, bar: string} + */ + +/** + * Handle space (Last one is a comment, correctly not replaced) + * + * @param array < integer , integer | boolean > | integer [ ] | array < array < integer > > integer + * @param array { integer: integer, boolean?: boolean } integer + */ + +/** + * null type should be the last one + * + * @param null|int + * @param string[]|null|int[] + * @param Foo|null|int[] + */ diff --git a/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.inc.fixed b/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.inc.fixed index b1fbe31..23a2f38 100644 --- a/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.inc.fixed +++ b/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.inc.fixed @@ -1,47 +1,68 @@ |int[]|array foo + * @param Generator bar + * @param Generator, array, array, array> baz + * @param array[]|bool + * @param array{0: int, 1?: int} + * @param array{int, int} + * @param array{foo: int, bar: string} */ -/** @var bool $a */ -/** @var bool $a */ -/** @var int $b */ -/** @var float $c */ -/** @var float $c */ +/** + * Handle space (Last one is a comment, correctly not replaced) + * + * @param array|int[]|array> integer + * @param array{integer: int, boolean?: bool} integer + */ -/** @method int|string */ -/** @method array|int[]|array truc */ -/** @method array|int[]|array> truc */ +/** + * null type should be the last one + * + * @param int|null + * @param string[]|int[]|null + * @param Foo|int[]|null + */ diff --git a/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.php b/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.php index 3fa9aa7..8063244 100644 --- a/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.php +++ b/SymfonyCustom/Tests/NamingConventions/ValidTypeHintUnitTest.php @@ -19,24 +19,34 @@ class ValidTypeHintUnitTest extends AbstractSniffUnitTest protected function getErrorList(): array { return [ - 26 => 1, + 5 => 1, + 10 => 1, + 11 => 1, + 12 => 1, + 13 => 1, + 19 => 1, + 20 => 1, 27 => 1, 28 => 1, 29 => 1, 30 => 1, - 32 => 1, - 33 => 1, - 34 => 1, - 35 => 1, - 36 => 1, + 31 => 1, + 37 => 1, + 38 => 1, 39 => 1, 40 => 1, - 41 => 1, - 42 => 1, - 43 => 1, - 45 => 1, 46 => 1, 47 => 1, + 48 => 1, + 49 => 1, + 50 => 1, + 51 => 1, + 52 => 1, + 58 => 1, + 59 => 1, + 65 => 1, + 66 => 1, + 67 => 1, ]; }