From 842e67948ca55a2f6abda44d9e03852b2ccfdbac Mon Sep 17 00:00:00 2001 From: James C <5689414+james-cnz@users.noreply.github.com> Date: Sat, 27 Apr 2024 18:34:25 +1200 Subject: [PATCH] Generic/PHPDocTypes and PSR5/PHPDocTypes: Adds sniffs Adds sniffs for PHPDoc types, both a generic sniff, and one for conformance to the PHP-FIG PSR-5 rules relating to types (using the generic sniff, with different properties set). --- .../Docs/Commenting/PHPDocTypesStandard.xml | 66 + .../Sniffs/Commenting/PHPDocTypesSniff.php | 1824 +++++++++++++++++ .../Tests/Commenting/PHPDocTypesUnitTest.php | 140 ++ .../PHPDocTypesUnitTest.right_php.inc | 152 ++ .../PHPDocTypesUnitTest.right_php_ns.inc | 38 + ...ocTypesUnitTest.right_type_non_php_fig.inc | 650 ++++++ .../PHPDocTypesUnitTest.wrong_core.inc | 93 + .../PHPDocTypesUnitTest.wrong_pass_splat.inc | 35 + .../PHPDocTypesUnitTest.wrong_php_parse.inc | 144 ++ ...PDocTypesUnitTest.wrong_tags_misplaced.inc | 59 + .../PHPDocTypesUnitTest.wrong_type_match.inc | 42 + .../PHPDocTypesUnitTest.wrong_type_parse.inc | 207 ++ .../Docs/Commenting/PHPDocTypesStandard.xml | 66 + .../Sniffs/Commenting/PHPDocTypesSniff.php | 70 + .../Tests/Commenting/PHPDocTypesUnitTest.php | 93 + .../PHPDocTypesUnitTest.right_type.inc | 317 +++ .../PHPDocTypesUnitTest.warn_docs_missing.inc | 29 + .../PHPDocTypesUnitTest.warn_tags_missing.inc | 36 + ...ocTypesUnitTest.wrong_type_non_php_fig.inc | 49 + .../PHPDocTypesUnitTest.wrong_type_style.inc | 67 + ...ocTypesUnitTest.wrong_type_style.inc.fixed | 67 + src/Standards/PSR5/ruleset.xml | 4 + src/Util/PHPDocTypesUtil.php | 1535 ++++++++++++++ 23 files changed, 5783 insertions(+) create mode 100644 src/Standards/Generic/Docs/Commenting/PHPDocTypesStandard.xml create mode 100644 src/Standards/Generic/Sniffs/Commenting/PHPDocTypesSniff.php create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.php create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_php.inc create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_php_ns.inc create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_type_non_php_fig.inc create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_core.inc create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_pass_splat.inc create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_php_parse.inc create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_tags_misplaced.inc create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_match.inc create mode 100644 src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_parse.inc create mode 100644 src/Standards/PSR5/Docs/Commenting/PHPDocTypesStandard.xml create mode 100644 src/Standards/PSR5/Sniffs/Commenting/PHPDocTypesSniff.php create mode 100644 src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.php create mode 100644 src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.right_type.inc create mode 100644 src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.warn_docs_missing.inc create mode 100644 src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.warn_tags_missing.inc create mode 100644 src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_non_php_fig.inc create mode 100644 src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_style.inc create mode 100644 src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_style.inc.fixed create mode 100644 src/Standards/PSR5/ruleset.xml create mode 100644 src/Util/PHPDocTypesUtil.php diff --git a/src/Standards/Generic/Docs/Commenting/PHPDocTypesStandard.xml b/src/Standards/Generic/Docs/Commenting/PHPDocTypesStandard.xml new file mode 100644 index 0000000000..588d1ab261 --- /dev/null +++ b/src/Standards/Generic/Docs/Commenting/PHPDocTypesStandard.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + diff --git a/src/Standards/Generic/Sniffs/Commenting/PHPDocTypesSniff.php b/src/Standards/Generic/Sniffs/Commenting/PHPDocTypesSniff.php new file mode 100644 index 0000000000..c89317b247 --- /dev/null +++ b/src/Standards/Generic/Sniffs/Commenting/PHPDocTypesSniff.php @@ -0,0 +1,1824 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +namespace PHP_CodeSniffer\Standards\Generic\Sniffs\Commenting; + +use PHP_CodeSniffer\Sniffs\Sniff; +use PHP_CodeSniffer\Files\File; +use PHP_CodeSniffer\Util\Tokens; +use PHP_CodeSniffer\Util\PHPDocTypesUtil; + +/** + * Check PHPDoc Types. + */ +class PHPDocTypesSniff implements Sniff +{ + + /** + * Check named classes and functions, and class variables and constants have doc blocks. + * Unless using this sniff standalone, probably disable this and use other sniffs for this. + * + * @var boolean + */ + public $checkHasDocBlocks = false; + + /** + * Check doc blocks, if present, contain appropriate type tags. + * + * @var boolean + */ + public $checkHasTags = false; + + /** + * Check there are no misplaced type tags--doesn't check for misplaced var tags. + * + * @var boolean + */ + public $checkTagsNotMisplaced = true; + + /** + * Check PHPDoc types and native types match--isn't aware of class heirarchies from other files, or global constants. + * + * @var boolean + */ + public $checkTypeMatch = true; + + /** + * Check built-in types are lower case, and short forms are used. + * + * @var boolean + */ + public $checkTypeStyle = false; + + /** + * Check the types used conform to the PHP-FIG PSR-5 PHPDoc standard. + * + * @var boolean + */ + public $checkTypePhpFig = false; + + /** + * Check pass by reference and splat usage matches for param tags. + * + * @var boolean + */ + public $checkPassSplat = true; + + /** + * Throw an error and stop if we can't parse the file. + * + * @var boolean + */ + public $debugMode = false; + + /** + * The current file. + * + * @var ?File + */ + protected $file = null; + + /** + * File tokens. + * + * @var array{ + * 'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int, + * 'parenthesis_opener'?: int, 'parenthesis_closer'?: int, 'attribute_closer'?: int, + * 'bracket_opener'?: int, 'bracket_closer'?: int, + * 'comment_tags'?: array, 'comment_closer'?: int + * }[] + */ + protected $tokens = []; + + /** + * Classish things: classes, interfaces, traits, and enums. + * + * @var array + */ + protected $artifacts = []; + + /** + * For parsing and comparing types. + * + * @var ?PHPDocTypesUtil + */ + protected $typesUtil = null; + + /** + * Pass 1 for gathering artifact/classish info, 2 for checking. + * + * @var 1|2 + */ + protected $pass = 1; + + /** + * Current token pointer in the file. + * + * @var integer + */ + protected $filePtr = 0; + + /** + * PHPDoc comment for upcoming declaration + * + * @var ?( + * \stdClass&object{ + * ptr: int, + * tags: array + * } + * ) + */ + protected $commentPending = null; + + /** + * The current token. + * + * @var array{ + * 'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int, + * 'parenthesis_opener'?: int, 'parenthesis_closer'?: int, 'attribute_closer'?: int, + * 'bracket_opener'?: int, 'bracket_closer'?: int, + * 'comment_tags'?: array, 'comment_closer'?: int + * } + */ + protected $token = [ + 'code' => null, + 'content' => '', + ]; + + /** + * The previous token. + * + * @var array{ + * 'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int, + * 'parenthesis_opener'?: int, 'parenthesis_closer'?: int, 'attribute_closer'?: int, + * 'bracket_opener'?: int, 'bracket_closer'?: int, + * 'comment_tags'?: array, 'comment_closer'?: int + * } + */ + protected $tokenPrevious = [ + 'code' => null, + 'content' => '', + ]; + + + /** + * Register for open tag. + * + * @return array-key[] + */ + public function register() + { + return [T_OPEN_TAG]; + + }//end register() + + + /** + * Processes PHP files and perform PHPDoc type checks with file. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr The position in the stack. + * + * @return int returns pointer to end of file to avoid being called further + */ + public function process(File $phpcsFile, $stackPtr) + { + + try { + $this->file = $phpcsFile; + $this->tokens = $phpcsFile->getTokens(); + + // Gather atifact info. + $this->artifacts = []; + if ($this->checkTypeMatch === true) { + $this->pass = 1; + $this->typesUtil = null; + $this->processPass($stackPtr); + } + + // Check the PHPDoc types. + $this->pass = 2; + $this->typesUtil = new PHPDocTypesUtil($this->artifacts); + $this->processPass($stackPtr); + } catch (\Exception $e) { + // We should only end up here in debug mode. + $this->file->addError( + 'The PHPDoc type sniff failed to parse the file. PHPDoc type checks were not performed. Error: '.$e->getMessage(), + min($this->filePtr, (count($this->tokens) - 1)), + 'PHPDocParse' + ); + }//end try + + return count($this->tokens); + + }//end process() + + + /** + * A pass over the file. + * + * @param int $stackPtr The position in the stack. + * + * @return void + * @phpstan-impure + */ + protected function processPass($stackPtr) + { + $scope = (object) [ + 'namespace' => '', + 'uses' => [], + 'templates' => [], + 'closer' => null, + 'className' => null, + 'parentName' => null, + 'type' => 'root', + ]; + $this->filePtr = $stackPtr; + $this->tokenPrevious = [ + 'code' => null, + 'content' => '', + ]; + $this->fetchToken(); + $this->commentPending = null; + + $this->processBlock($scope, 0); + + }//end processPass() + + + /** + * Process the content of a file, class, function, or parameters + * + * @param \stdClass&object{namespace: string, uses: array, templates: array, className: ?string, parentName: ?string, type: string, closer: ?int} $scope Scope + * @param 0|1|2 $type 0=file 1=block 2=parameters + * + * @return void + * @phpstan-impure + */ + protected function processBlock($scope, $type) + { + + // Check we are at the start of a scope, and store scope closer. + if ($type === 0) { + // File. + if ($this->debugMode === true && $this->token['code'] !== T_OPEN_TAG) { + // We shouldn't ever end up here. + throw new \Exception('Expected PHP open tag.'); + } + + $scope->closer = count($this->tokens); + } else if ($type === 1) { + // Block. + if (isset($this->token['scope_opener']) === false + || $this->token['scope_opener'] !== $this->filePtr + || isset($this->token['scope_closer']) === false + ) { + throw new \Exception('Malformed block.'); + } + + $scope->closer = $this->token['scope_closer']; + } else { + // Parameters. + if (isset($this->token['parenthesis_opener']) === false + || $this->token['parenthesis_opener'] !== $this->filePtr + || isset($this->token['parenthesis_closer']) === false + ) { + throw new \Exception('Malformed parameters.'); + } + + $scope->closer = $this->token['parenthesis_closer']; + }//end if + + $this->advance(); + + while (true) { + // If parsing fails, we'll give up whatever we're doing, and try again. + try { + // Skip irrelevant tokens. + while (in_array( + $this->token['code'], + array_merge( + [ + T_NAMESPACE, + T_USE, + ], + Tokens::$methodPrefixes, + [ + T_ATTRIBUTE, + T_READONLY, + ], + Tokens::$ooScopeTokens, + [ + T_FUNCTION, + T_CLOSURE, + T_FN, + T_VAR, + T_CONST, + null, + ] + ) + ) === false + && ($this->filePtr < $scope->closer) + ) { + $this->advance(); + } + + if ($this->filePtr >= $scope->closer) { + // End of the block. + break; + } else if ($this->token['code'] === T_NAMESPACE && $scope->type === 'root') { + // Namespace. + $this->processNamespace($scope); + } else if ($this->token['code'] === T_USE && ($scope->type === 'root' || $scope->type === 'namespace')) { + // Use. + $this->processUse($scope); + } else if ($this->token['code'] === T_USE && $scope->type === 'classish') { + // Class trait use. + $this->processClassTraitUse(); + } else if (in_array( + $this->token['code'], + array_merge( + Tokens::$methodPrefixes, + [ + T_ATTRIBUTE, + T_READONLY, + ], + Tokens::$ooScopeTokens, + [ + T_FUNCTION, + T_CLOSURE, + T_FN, + T_CONST, + T_VAR, + ] + ) + ) === true + ) { + // Maybe declaration. + // Fetch comment, if any. + $comment = $this->commentPending; + $this->commentPending = null; + // Ignore attribute(s). + while ($this->token['code'] === T_ATTRIBUTE) { + while ($this->token['code'] !== T_ATTRIBUTE_END) { + $this->advance(); + } + + $this->advance(T_ATTRIBUTE_END); + } + + // Check this still looks like a declaration. + if (in_array( + $this->token['code'], + array_merge( + Tokens::$methodPrefixes, + [T_READONLY], + Tokens::$ooScopeTokens, + [ + T_FUNCTION, + T_CLOSURE, + T_FN, + T_CONST, + T_VAR, + ] + ) + ) === false + ) { + // It's not a declaration, possibly an enum case. + $this->processPossVarComment($scope, $comment); + continue; + } + + // Ignore other preceding stuff, and gather info to check for static late bindings. + $static = false; + $staticprecededbynew = ($this->tokenPrevious['code'] === T_NEW); + while (in_array( + $this->token['code'], + array_merge(Tokens::$methodPrefixes, [T_READONLY]) + ) === true + ) { + $static = $static || ($this->token['code'] === T_STATIC); + $this->advance(); + } + + // What kind of declaration is this? + if ($static === true && ($this->token['code'] === T_DOUBLE_COLON || $staticprecededbynew === true)) { + // It's not a declaration, it's a static late binding. + $this->processPossVarComment($scope, $comment); + continue; + } else if (in_array($this->token['code'], Tokens::$ooScopeTokens) === true) { + // Classish thing. + $this->processClassish($scope, $comment); + } else if (in_array($this->token['code'], [T_FUNCTION, T_CLOSURE, T_FN]) === true) { + // Function. + $this->processFunction($scope, $comment); + } else { + // Variable. + $this->processVariable($scope, $comment); + } + } else { + // We got something unrecognised. + $this->advance(); + throw new \Exception('Unrecognised construct.'); + }//end if + } catch (\Exception $e) { + // Just give up on whatever we're doing and try again, unless in debug mode. + if ($this->debugMode === true) { + throw $e; + } + }//end try + }//end while + + // Check we are at the end of the scope. + if (($type !== 0 || $this->debugMode === true) && $this->filePtr !== $scope->closer) { + throw new \Exception('Malformed scope closer.'); + } + + }//end processBlock() + + + /** + * Fetch the current tokens. + * + * @return void + * @phpstan-impure + */ + protected function fetchToken() + { + if ($this->filePtr < count($this->tokens)) { + $this->token = $this->tokens[$this->filePtr]; + } else { + $this->token = [ + 'code' => null, + 'content' => '', + ]; + } + + }//end fetchToken() + + + /** + * Advance the token pointer when reading PHP code. + * + * @param array-key $expectedCode What we expect, or null if anything's OK + * + * @return void + * @phpstan-impure + */ + protected function advance($expectedCode=null) + { + + // Check we have something to fetch, and it's what's expected. + if (($expectedCode !== null && $this->token['code'] !== $expectedCode) || $this->token['code'] === null) { + throw new \Exception("Unexpected token, saw: \"{$this->token['content']}\"."); + } + + // Dispose of unused comment, if any. + if ($this->commentPending !== null) { + $this->processPossVarComment(null, $this->commentPending); + $this->commentPending = null; + } + + $this->tokenPrevious = $this->token; + + $this->filePtr++; + $this->fetchToken(); + + // Skip stuff that doesn't affect us, and process PHPDoc comments. + while ($this->filePtr < count($this->tokens) + && in_array($this->tokens[$this->filePtr]['code'], Tokens::$emptyTokens) === true + ) { + if (in_array($this->tokens[$this->filePtr]['code'], [T_DOC_COMMENT_OPEN_TAG, T_DOC_COMMENT]) === true) { + // Dispose of unused comment, if any. + if ($this->pass === 2 && $this->commentPending !== null) { + $this->processPossVarComment(null, $this->commentPending); + $this->commentPending = null; + } + + // Fetch new comment. + $this->processComment(); + } else { + $this->filePtr++; + $this->fetchToken(); + } + } + + // If we're at the end of the file, dispose of unused comment, if any. + if ($this->token['code'] === null && $this->pass === 2 && $this->commentPending !== null) { + $this->processPossVarComment(null, $this->commentPending); + $this->commentPending = null; + } + + }//end advance() + + + /** + * Find following token + * + * @return array{ + * 'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int, + * 'parenthesis_opener'?: int, 'parenthesis_closer'?: int, 'attribute_closer'?: int, + * 'bracket_opener'?: int, 'bracket_closer'?: int, + * 'comment_tags'?: array, 'comment_closer'?: int + * } + */ + protected function lookAhead() + { + $filePtr = ($this->filePtr + 1); + + // Skip stuff that doesn't affect us. + while ($filePtr < count($this->tokens) + && in_array($this->tokens[$filePtr]['code'], Tokens::$emptyTokens) === true + ) { + $filePtr++; + } + + if ($filePtr < count($this->tokens)) { + return $this->tokens[$filePtr]; + } else { + return [ + 'code' => null, + 'content' => '', + ]; + } + + }//end lookAhead() + + + /** + * Advance the token pointer to a specific point. + * + * @param int $newPtr Where to advance to + * + * @return void + * @phpstan-impure + */ + protected function advanceTo($newPtr) + { + while ($this->filePtr < $newPtr) { + $this->advance(); + } + + if ($this->filePtr !== $newPtr) { + throw new \Exception('Malformed code.'); + } + + }//end advanceTo() + + + /** + * Process a PHPDoc comment. + * + * @return void + * @phpstan-impure + */ + protected function processComment() + { + $commentPtr = $this->filePtr; + $this->commentPending = (object) [ + 'ptr' => $commentPtr, + 'tags' => [], + ]; + $this->filePtr++; + $this->fetchToken(); + + if (isset($this->tokens[$commentPtr]['comment_tags']) === false) { + throw new \Exception('Comment tags not found.'); + } + + // For each tag. + foreach ($this->tokens[$commentPtr]['comment_tags'] as $tagPtr) { + $this->filePtr = $tagPtr; + $this->fetchToken(); + + $tag = (object) [ + 'ptr' => $tagPtr, + 'content' => '', + 'cStartPtr' => null, + 'cEndPtr' => null, + ]; + + // Fetch the tag type. + $tagType = $this->token['content']; + $this->filePtr++; + $this->fetchToken(); + + // Skip line starting stuff. + while ($this->token['code'] === T_DOC_COMMENT_WHITESPACE + && in_array(substr($this->token['content'], -1), ["\n", "\r"]) === false + ) { + $this->filePtr++; + $this->fetchToken(); + } + + // For each line, until we reach a new tag. + // Note: the logic for fixing a comment tag must exactly match this. + do { + // Fetch line content. + $newline = false; + while ($this->token['code'] !== null && $this->token['code'] !== T_DOC_COMMENT_CLOSE_TAG && $newline === false) { + if ($tag->cStartPtr === null) { + $tag->cStartPtr = $this->filePtr; + } + + $tag->cEndPtr = $this->filePtr; + $newline = in_array(substr($this->token['content'], -1), ["\n", "\r"]) === true; + if ($newline === true) { + $tag->content .= "\n"; + } else { + $tag->content .= $this->token['content']; + } + + $this->filePtr++; + $this->fetchToken(); + } + + // Skip next line starting stuff. + while ($this->token['code'] === T_DOC_COMMENT_STAR + || ($this->token['code'] === T_DOC_COMMENT_WHITESPACE + && in_array(substr($this->token['content'], -1), ["\n", "\r"]) === false) + ) { + $this->filePtr++; + $this->fetchToken(); + } + } while (in_array($this->token['code'], [T_DOC_COMMENT_TAG, T_DOC_COMMENT_CLOSE_TAG, null]) === false); + + // Store tag content. + if (isset($this->commentPending->tags[$tagType]) === false) { + $this->commentPending->tags[$tagType] = []; + } + + $this->commentPending->tags[$tagType][] = $tag; + }//end foreach + + if (isset($this->tokens[$commentPtr]['comment_closer']) === false) { + throw new \Exception('End of PHPDoc comment not found.'); + } + + $this->filePtr = $this->tokens[$commentPtr]['comment_closer']; + $this->fetchToken(); + if ($this->token['code'] !== T_DOC_COMMENT_CLOSE_TAG) { + throw new \Exception('End of PHPDoc comment not found.'); + } + + $this->filePtr++; + $this->fetchToken(); + + }//end processComment() + + + /** + * Check for misplaced tags + * + * @param object{ptr: int, tags: array} $comment PHPDoc block + * @param string[] $tagNames What we shouldn't have + * + * @return void + */ + protected function checkNo($comment, $tagNames) + { + if ($this->checkTagsNotMisplaced === false) { + return; + } + + foreach ($tagNames as $tagName) { + if (isset($comment->tags[$tagName]) === true) { + $this->file->addError( + 'PHPDoc misplaced tag', + $comment->tags[$tagName][0]->ptr, + 'PHPDocTagMisplaced' + ); + } + } + + }//end checkNo() + + + /** + * Fix a PHPDoc comment tag. + * + * @param object{ptr: int, content: string, cStartPtr: ?int, cEndPtr: ?int} $tag The PHPDoc tag to be fixed + * @param string $replacement Replacement text + * + * @return void + * @phpstan-impure + */ + protected function fixCommentTag($tag, $replacement) + { + $replacementArray = explode("\n", $replacement); + // Place in the replacement array. + $replacementCounter = 0; + // Have we done the replacement at the current position in the array? + $doneReplacement = false; + $ptr = $tag->cStartPtr; + + $this->file->fixer->beginChangeset(); + + // For each line, until we reach a new tag. + // Note: the logic for this must exactly match that for processing a comment tag. + do { + // Change line content. + $newline = false; + while ($this->tokens[$ptr]['code'] !== null && $this->tokens[$ptr]['code'] !== T_DOC_COMMENT_CLOSE_TAG && $newline === false) { + $newline = in_array(substr($this->tokens[$ptr]['content'], -1), ["\n", "\r"]); + if ($newline === false) { + if ($doneReplacement === true || $replacementArray[$replacementCounter] === '') { + // We shouldn't ever end up here. + throw new \Exception('Error during replacement.'); + } + + $this->file->fixer->replaceToken($ptr, $replacementArray[$replacementCounter]); + $doneReplacement = true; + } else { + if (($doneReplacement === true || $replacementArray[$replacementCounter] === '') === false) { + // We shouldn't ever end up here. + throw new \Exception('Error during replacement.'); + } + + $replacementCounter++; + $doneReplacement = false; + } + + $ptr++; + }//end while + + // Skip next line starting stuff. + while ($this->tokens[$ptr]['code'] === T_DOC_COMMENT_STAR + || ($this->tokens[$ptr]['code'] === T_DOC_COMMENT_WHITESPACE + && in_array(substr($this->tokens[$ptr]['content'], -1), ["\n", "\r"]) === false) + ) { + $ptr++; + } + } while (in_array($this->tokens[$ptr]['code'], [T_DOC_COMMENT_TAG, T_DOC_COMMENT_CLOSE_TAG, null]) === false); + + // Check we're done all the expected replacements, otherwise something's gone seriously wrong. + if (($replacementCounter === count($replacementArray) - 1 + && ($doneReplacement === true || $replacementArray[(count($replacementArray) - 1)] === '')) === false + ) { + // We shouldn't ever end up here. + throw new \Exception('Error during replacement.'); + } + + $this->file->fixer->endChangeset(); + + }//end fixCommentTag() + + + /** + * Process a namespace declaration. + * + * @param \stdClass&object{namespace: string, uses: array, templates: array, className: ?string, parentName: ?string, type: string, closer: ?int} $scope Scope + * + * @return void + * @phpstan-impure + */ + protected function processNamespace($scope) + { + + $this->advance(T_NAMESPACE); + + // Fetch the namespace. + $namespace = ''; + while (in_array( + $this->token['code'], + [ + T_NAME_FULLY_QUALIFIED, + T_NAME_QUALIFIED, + T_NAME_RELATIVE, + T_NS_SEPARATOR, + T_STRING, + ] + ) === true + ) { + $namespace .= $this->token['content']; + $this->advance(); + } + + // Check it's right. + if ($namespace !== '' && $namespace[(strlen($namespace) - 1)] === '\\') { + throw new \Exception('Namespace trailing backslash.'); + } + + // Check it's fully qualified. + if ($namespace !== '' && $namespace[0] !== '\\') { + $namespace = '\\'.$namespace; + } + + if (in_array($this->token['code'], [T_OPEN_CURLY_BRACKET, T_SEMICOLON]) === false) { + throw new \Exception('Namespace malformed.'); + } + + // What kind of namespace is it? + if ($this->token['code'] === T_OPEN_CURLY_BRACKET) { + $scope = clone($scope); + $scope->type = 'namespace'; + $scope->namespace = $namespace; + $this->processBlock($scope, 1); + } else { + $scope->namespace = $namespace; + $this->advance(T_SEMICOLON); + } + + }//end processNamespace() + + + /** + * Process a use declaration. + * + * @param \stdClass&object{namespace: string, uses: array, templates: array, className: ?string, parentName: ?string, type: string, closer: ?int} $scope Scope + * + * @return void + * @phpstan-impure + */ + protected function processUse($scope) + { + + $this->advance(T_USE); + + // Loop until we've fetched all imports. + $more = false; + do { + // Get the type. + $type = 'class'; + if ($this->token['code'] === T_FUNCTION) { + $type = 'function'; + $this->advance(T_FUNCTION); + } else if ($this->token['code'] === T_CONST) { + $type = 'const'; + $this->advance(T_CONST); + } + + // Get what's being imported. + $namespace = ''; + while (in_array( + $this->token['code'], + [ + T_NAME_FULLY_QUALIFIED, + T_NAME_QUALIFIED, + T_NAME_RELATIVE, + T_NS_SEPARATOR, + T_STRING, + ] + ) === true + ) { + $namespace .= $this->token['content']; + $this->advance(); + } + + // Check it's fully qualified. + if ($namespace !== '' && $namespace[0] !== '\\') { + $namespace = '\\'.$namespace; + } + + if ($this->token['code'] === T_OPEN_USE_GROUP) { + // It's a group. + $namespaceStart = $namespace; + if ($namespaceStart !== '' && strrpos($namespaceStart, '\\') !== (strlen($namespaceStart) - 1)) { + throw new \Exception("Namespace for use group doesn't have trailing back slash."); + } + + $typeStart = $type; + + // Fetch everything in the group. + $maybeMore = false; + $this->advance(T_OPEN_USE_GROUP); + do { + // Get the type. + $type = $typeStart; + if ($this->token['code'] === T_FUNCTION) { + $type = 'function'; + $this->advance(T_FUNCTION); + } else if ($this->token['code'] === T_CONST) { + $type = 'const'; + $this->advance(T_CONST); + } + + // Get what's being imported. + $namespace = $namespaceStart; + while (in_array( + $this->token['code'], + [ + T_NAME_FULLY_QUALIFIED, + T_NAME_QUALIFIED, + T_NAME_RELATIVE, + T_NS_SEPARATOR, + T_STRING, + ] + ) === true + ) { + $namespace .= $this->token['content']; + $this->advance(); + } + + // Figure out the alias. + $alias = substr($namespace, (strrpos($namespace, '\\') + 1)); + if ($alias === false || $alias === '') { + throw new \Exception('Use item has trailing back slash.'); + } + + $asAlias = $this->processUseAsAlias(); + if ($asAlias !== null) { + $alias = $asAlias; + } + + // Store it. + if ($type === 'class') { + $scope->uses[$alias] = $namespace; + } + + $maybeMore = ($this->token['code'] === T_COMMA); + if ($maybeMore === true) { + $this->advance(T_COMMA); + } + } while ($maybeMore === true && $this->token['code'] !== T_CLOSE_USE_GROUP); + $this->advance(T_CLOSE_USE_GROUP); + } else { + // It's a single import. + // Figure out the alias. + if (strrpos($namespace, '\\') !== false) { + $alias = substr($namespace, (strrpos($namespace, '\\') + 1)); + } else { + $alias = $namespace; + } + + if ($alias === false || $alias === '') { + throw new \Exception('Use name has trailing back slash.'); + } + + $asAlias = $this->processUseAsAlias(); + if ($asAlias !== null) { + $alias = $asAlias; + } + + // Store it. + if ($type === 'class') { + $scope->uses[$alias] = $namespace; + } + }//end if + + $more = ($this->token['code'] === T_COMMA); + if ($more === true) { + $this->advance(T_COMMA); + } + } while ($more === true); + + $this->advance(T_SEMICOLON); + + }//end processUse() + + + /** + * Process a use as alias. + * + * @return ?string + * @phpstan-impure + */ + protected function processUseAsAlias() + { + $alias = null; + if ($this->token['code'] === T_AS) { + $this->advance(T_AS); + $alias = $this->token['content']; + $this->advance(T_STRING); + } + + return $alias; + + }//end processUseAsAlias() + + + /** + * Process a classish thing. + * + * @param \stdClass&object{namespace: string, uses: array, templates: array, className: ?string, parentName: ?string, type: string, closer: ?int} $scope Scope + * @param ?(\stdClass&object{ptr: int, tags: array}) $comment PHPDoc block + * + * @return void + * @phpstan-impure + */ + protected function processClassish($scope, $comment) + { + + $ptr = $this->filePtr; + $token = $this->token; + $this->advance(); + + // New scope. + $scope = clone($scope); + $scope->type = 'classish'; + $scope->closer = null; + + // Get details. + $name = $this->file->getDeclarationName($ptr); + if ($name !== null) { + $name = $scope->namespace.'\\'.$name; + } + + $parent = $this->file->findExtendedClassName($ptr); + if ($parent === false) { + $parent = null; + } else if ($parent !== null && $parent[0] !== '\\') { + if (isset($scope->uses[$parent]) === true) { + $parent = $scope->uses[$parent]; + } else { + $parent = $scope->namespace.'\\'.$parent; + } + } + + $interfaces = $this->file->findImplementedInterfaceNames($ptr); + if ($interfaces === false) { + $interfaces = []; + } + + foreach ($interfaces as $index => $interface) { + if ($interface !== '' && $interface[0] !== '\\') { + if (isset($scope->uses[$interface]) === true) { + $interfaces[$index] = $scope->uses[$interface]; + } else { + $interfaces[$index] = $scope->namespace.'\\'.$interface; + } + } + } + + $scope->className = $name; + $scope->parentName = $parent; + + if ($this->pass === 1 && $name !== null) { + // Store details. + $this->artifacts[$name] = (object) [ + 'extends' => $parent, + 'implements' => $interfaces, + ]; + } else if ($this->pass === 2) { + // Check for missing docs if not anonymous. + if ($this->checkHasDocBlocks === true && $name !== null && $comment === null) { + $this->file->addWarning( + 'PHPDoc class is not documented', + $ptr, + 'PHPDocClassDocMissing' + ); + } + + // Check no misplaced tags. + if ($comment !== null) { + $this->checkNo($comment, ['@param', '@return', '@var']); + } + + // Check and store templates. + if ($comment !== null && isset($comment->tags['@template']) === true) { + $this->processTemplates($scope, $comment); + } + + // Check properties. + if ($comment !== null) { + // Check each property type. + foreach (['@property', '@property-read', '@property-write'] as $tagName) { + if (isset($comment->tags[$tagName]) === false) { + $comment->tags[$tagName] = []; + } + + // Check each individual property. + foreach ($comment->tags[$tagName] as $docProp) { + $docPropParsed = $this->typesUtil->parseTypeAndName( + $scope, + $docProp->content, + 1, + false + ); + if ($docPropParsed->type === null) { + $this->file->addError( + 'PHPDoc class property type error: '.$docPropParsed->err, + $docProp->ptr, + 'PHPDocClassPropType' + ); + } else if ($docPropParsed->name === null) { + $this->file->addError( + 'PHPDoc class property name missing or malformed', + $docProp->ptr, + 'PHPDocClassPropName' + ); + } else { + if ($this->checkTypePhpFig === true && $docPropParsed->phpFig === false) { + $this->file->addError( + "PHPDoc class property type doesn't conform to PHP-FIG PSR-5", + $docProp->ptr, + 'PHPDocClassPropTypePHPFIG' + ); + } + + if ($this->checkTypeStyle === true && $docPropParsed->fixed !== null) { + $fix = $this->file->addFixableError( + "PHPDoc class property type doesn't conform to recommended style", + $docProp->ptr, + 'PHPDocClassPropTypeStyle' + ); + if ($fix === true) { + $this->fixCommentTag( + $docProp, + $docPropParsed->fixed + ); + } + } + }//end if + }//end foreach + }//end foreach + }//end if + }//end if + + if (isset($token['parenthesis_opener']) === true) { + $parametersPtr = $token['parenthesis_opener']; + } else { + $parametersPtr = null; + } + + if (isset($token['scope_opener']) === true) { + $blockPtr = $token['scope_opener']; + } else { + $blockPtr = null; + } + + // If it's an anonymous class, it could have parameters. + // And those parameters could have other anonymous classes or functions in them. + if ($parametersPtr !== null) { + $this->advanceTo($parametersPtr); + $this->processBlock($scope, 2); + } + + // Process the content. + if ($blockPtr !== null) { + $this->advanceTo($blockPtr); + $this->processBlock($scope, 1); + }; + + }//end processClassish() + + + /** + * Skip over a class trait usage. + * We need to ignore these, because if it's got public, protected, or private in it, + * it could be confused for a declaration. + * + * @return void + * @phpstan-impure + */ + protected function processClassTraitUse() + { + $this->advance(T_USE); + + $more = false; + do { + while (in_array( + $this->token['code'], + [ + T_NAME_FULLY_QUALIFIED, + T_NAME_QUALIFIED, + T_NAME_RELATIVE, + T_NS_SEPARATOR, + T_STRING, + ] + ) === true + ) { + $this->advance(); + } + + if ($this->token['code'] === T_OPEN_CURLY_BRACKET) { + if (isset($this->token['bracket_opener']) === false || isset($this->token['bracket_closer']) === false) { + throw new \Exception('Malformed class trait use group.'); + } + + $this->advanceTo($this->token['bracket_closer']); + $this->advance(T_CLOSE_CURLY_BRACKET); + } + + $more = ($this->token['code'] === T_COMMA); + if ($more === true) { + $this->advance(T_COMMA); + } + } while ($more === true); + + }//end processClassTraitUse() + + + /** + * Process a function. + * + * @param \stdClass&object{namespace: string, uses: array, templates: array, className: ?string, parentName: ?string, type: string, closer: ?int} $scope Scope + * @param ?(\stdClass&object{ptr: int, tags: array}) $comment PHPDoc block + * + * @return void + * @phpstan-impure + */ + protected function processFunction($scope, $comment) + { + + $ptr = $this->filePtr; + $token = $this->token; + $this->advance(); + + // New scope. + $scope = clone($scope); + $scope->type = 'function'; + $scope->closer = null; + + // Get details. + if ($token['code'] !== T_FN) { + $name = $this->file->getDeclarationName($ptr); + } else { + $name = null; + } + + if (isset($token['parenthesis_opener']) === true) { + $parametersPtr = $token['parenthesis_opener']; + } else { + $parametersPtr = null; + } + + if (isset($token['scope_opener']) === true) { + $blockPtr = $token['scope_opener']; + } else { + $blockPtr = null; + } + + if ($parametersPtr === null + || isset($this->tokens[$parametersPtr]['parenthesis_opener']) === false + || isset($this->tokens[$parametersPtr]['parenthesis_closer']) === false + ) { + throw new \Exception('Malformed function parameters.'); + } + + $parameters = $this->file->getMethodParameters($ptr); + $properties = $this->file->getMethodProperties($ptr); + + // Checks. + if ($this->pass === 2) { + // Check for missing docs if not anonymous. + if ($this->checkHasDocBlocks === true && $name !== null && $comment === null) { + $this->file->addWarning( + 'PHPDoc function is not documented', + $ptr, + 'PHPDocFunDocMissing' + ); + } + + // Check for misplaced tags. + if ($comment !== null) { + $this->checkNo($comment, ['@property', '@property-read', '@property-write', '@var']); + } + + // Check and store templates. + if ($comment !== null && isset($comment->tags['@template']) === true) { + $this->processTemplates($scope, $comment); + } + + // Check parameter types. + if ($comment !== null) { + // Gather parameter data. + $paramParsedArray = []; + foreach ($parameters as $parameter) { + $paramText = trim($parameter['content']); + while (($spacePos = strpos($paramText, ' ')) !== false + && in_array( + strtolower(substr($paramText, 0, $spacePos)), + [ + 'public', + 'private', + 'protected', + 'readonly', + ] + ) === true + ) { + $paramText = trim(substr($paramText, (strpos($paramText, ' ') + 1))); + } + + $paramParsed = $this->typesUtil->parseTypeAndName( + $scope, + $paramText, + 3, + true + ); + if ($paramParsed->name !== null && isset($paramParsedArray[$paramParsed->name]) === false) { + $paramParsedArray[$paramParsed->name] = $paramParsed; + } + }//end foreach + + if (isset($comment->tags['@param']) === false) { + $comment->tags['@param'] = []; + } + + // Check each individual doc parameter. + $docParamsMatched = []; + foreach ($comment->tags['@param'] as $docParam) { + $docParamParsed = $this->typesUtil->parseTypeAndName( + $scope, + $docParam->content, + 2, + false + ); + if ($docParamParsed->type === null) { + $this->file->addError( + 'PHPDoc function parameter type error: '.$docParamParsed->err, + $docParam->ptr, + 'PHPDocFunParamType' + ); + } else if ($docParamParsed->name === null) { + $this->file->addError( + 'PHPDoc function parameter name missing or malformed', + $docParam->ptr, + 'PHPDocFunParamName' + ); + } else if (isset($paramParsedArray[$docParamParsed->name]) === false) { + // Function parameter doesn't exist. + $this->file->addError( + "PHPDoc function parameter doesn't exist", + $docParam->ptr, + 'PHPDocFunParamNameWrong' + ); + } else { + // Compare docs against actual parameter. + $paramParsed = $paramParsedArray[$docParamParsed->name]; + + if (isset($docParamsMatched[$docParamParsed->name]) === true) { + $this->file->addError( + 'PHPDoc function parameter repeated', + $docParam->ptr, + 'PHPDocFunParamNameMultiple' + ); + } + + $docParamsMatched[$docParamParsed->name] = true; + + if ($this->checkTypeMatch === true + && $this->typesUtil->comparetypes($paramParsed->type, $docParamParsed->type) === false + ) { + $this->file->addError( + 'PHPDoc function parameter type mismatch', + $docParam->ptr, + 'PHPDocFunParamTypeMismatch' + ); + } + + if ($this->checkTypePhpFig === true && $docParamParsed->phpFig === false) { + $this->file->addError( + "PHPDoc function parameter type doesn't conform to PHP-FIG PSR-5", + $docParam->ptr, + 'PHPDocFunParamTypePHPFIG' + ); + } + + if ($this->checkTypeStyle === true && $docParamParsed->fixed !== null) { + $fix = $this->file->addFixableError( + "PHPDoc function parameter type doesn't conform to recommended style", + $docParam->ptr, + 'PHPDocFunParamTypeStyle' + ); + if ($fix === true) { + $this->fixCommentTag( + $docParam, + $docParamParsed->fixed + ); + } + } + + if ($this->checkPassSplat === true && $paramParsed->passSplat !== $docParamParsed->passSplat) { + $this->file->addError( + 'PHPDoc function parameter pass by reference or splat mismatch', + $docParam->ptr, + 'PHPDocFunParamPassSplatMismatch' + ); + } + }//end if + }//end foreach + + // Check all parameters are documented (if all documented parameters were recognised). + if ($this->checkHasTags === true && count($docParamsMatched) === count($comment->tags['@param'])) { + foreach ($paramParsedArray as $paramname => $paramParsed) { + if (isset($docParamsMatched[$paramname]) === false) { + $this->file->addWarning( + 'PHPDoc function parameter %s not documented', + $comment->ptr, + 'PHPDocFunParamTagMissing', + [$paramname] + ); + } + } + } + + // Check parameters are in the correct order. + reset($paramParsedArray); + reset($docParamsMatched); + while (key($paramParsedArray) !== null || key($docParamsMatched) !== null) { + if (key($docParamsMatched) === key($paramParsedArray)) { + next($paramParsedArray); + next($docParamsMatched); + } else if (key($paramParsedArray) !== null && isset($docParamsMatched[key($paramParsedArray)]) === false) { + next($paramParsedArray); + } else { + $this->file->addWarning( + 'PHPDoc function parameter order wrong', + $comment->ptr, + 'PHPDocFunParamTagOrder' + ); + break; + } + } + }//end if + + // Check return type. + if ($comment !== null) { + if ($properties['return_type'] !== '') { + $retParsed = $this->typesUtil->parseTypeAndName( + $scope, + $properties['return_type'], + 0, + true + ); + } else { + $retParsed = (object) ['type' => 'mixed']; + } + + if (isset($comment->tags['@return']) === false) { + $comment->tags['@return'] = []; + } + + if ($this->checkHasTags === true && count($comment->tags['@return']) < 1 + && $name !== '__construct' && $retParsed->type !== 'void' + ) { + $this->file->addWarning( + 'PHPDoc missing function @return tag', + $comment->ptr, + 'PHPDocFunRetTagMissing' + ); + } else if (count($comment->tags['@return']) > 1) { + $this->file->addError( + 'PHPDoc multiple function @return tags--Put in one tag, seperated by vertical bars |', + $comment->tags['@return'][1]->ptr, + 'PHPDocFunRetTagMultiple' + ); + } + + // Check each individual return tag, in case there's more than one. + foreach ($comment->tags['@return'] as $docRet) { + $docRetParsed = $this->typesUtil->parseTypeAndName( + $scope, + $docRet->content, + 0, + false + ); + + if ($docRetParsed->type === null) { + $this->file->addError( + 'PHPDoc function return type error: '.$docRetParsed->err, + $docRet->ptr, + 'PHPDocFunRetType' + ); + } else { + if ($this->checkTypeMatch === true + && $this->typesUtil->comparetypes($retParsed->type, $docRetParsed->type) === false + ) { + $this->file->addError( + 'PHPDoc function return type mismatch', + $docRet->ptr, + 'PHPDocFunRetTypeMismatch' + ); + } + + if ($this->checkTypePhpFig === true && $docRetParsed->phpFig === false) { + $this->file->addError( + "PHPDoc function return type doesn't conform to PHP-FIG PSR-5", + $docRet->ptr, + 'PHPDocFunRetTypePHPFIG' + ); + } + + if ($this->checkTypeStyle === true && $docRetParsed->fixed !== null) { + $fix = $this->file->addFixableError( + "PHPDoc function return type doesn't conform to recommended style", + $docRet->ptr, + 'PHPDocFunRetTypeStyle' + ); + if ($fix === true) { + $this->fixCommentTag( + $docRet, + $docRetParsed->fixed + ); + } + } + }//end if + }//end foreach + }//end if + }//end if + + // Parameters could contain anonymous classes or functions. + $this->advanceTo($parametersPtr); + $this->processBlock($scope, 2); + + // Content. + if ($blockPtr !== null) { + $this->advanceTo($blockPtr); + $this->processBlock($scope, 1); + }; + + }//end processFunction() + + + /** + * Process templates. + * + * @param \stdClass&object{namespace: string, uses: array, templates: array, className: ?string, parentName: ?string, type: string, closer: ?int} $scope Scope + * @param ?(\stdClass&object{ptr: int, tags: array}) $comment PHPDoc block + * + * @return void + * @phpstan-impure + */ + protected function processTemplates($scope, $comment) + { + foreach ($comment->tags['@template'] as $docTemplate) { + $docTemplateParsed = $this->typesUtil->parseTemplate($scope, $docTemplate->content); + if ($docTemplateParsed->name === null) { + $this->file->addError( + 'PHPDoc template name missing or malformed', + $docTemplate->ptr, + 'PHPDocTemplateName' + ); + } else if ($docTemplateParsed->type === null) { + $this->file->addError( + 'PHPDoc template type error: '.$docTemplateParsed->err, + $docTemplate->ptr, + 'PHPDocTemplateType' + ); + $scope->templates[$docTemplateParsed->name] = 'never'; + } else { + $scope->templates[$docTemplateParsed->name] = $docTemplateParsed->type; + + if ($this->checkTypePhpFig === true && $docTemplateParsed->phpFig === false) { + $this->file->addError( + "PHPDoc template type doesn't conform to PHP-FIG PSR-5", + $docTemplate->ptr, + 'PHPDocTemplateTypePHPFIG' + ); + } + + if ($this->checkTypeStyle === true && $docTemplateParsed->fixed !== null) { + $fix = $this->file->addFixableError( + "PHPDoc tempate type doesn't conform to recommended style", + $docTemplate->ptr, + 'PHPDocTemplateTypeStyle' + ); + if ($fix === true) { + $this->fixCommentTag( + $docTemplate, + $docTemplateParsed->fixed + ); + } + } + }//end if + }//end foreach + + }//end processTemplates() + + + /** + * Process a variable. + * + * @param \stdClass&object{namespace: string, uses: array, templates: array, className: ?string, parentName: ?string, type: string, closer: ?int} $scope Scope + * @param ?(\stdClass&object{ptr: int, tags: array}) $comment PHPDoc block + * + * @return void + * @phpstan-impure + */ + protected function processVariable($scope, $comment) + { + + // Parse var/const token. + $const = ($this->token['code'] === T_CONST); + if ($const === true) { + $this->advance(T_CONST); + } else if ($this->token['code'] === T_VAR) { + $this->advance(T_VAR); + } + + // Parse type. + $varType = ''; + while (in_array( + $this->token['code'], + [ + T_TYPE_UNION, + T_TYPE_INTERSECTION, + T_NULLABLE, + T_OPEN_PARENTHESIS, + T_CLOSE_PARENTHESIS, + T_NAME_FULLY_QUALIFIED, + T_NAME_QUALIFIED, + T_NAME_RELATIVE, + T_NS_SEPARATOR, + T_STRING, + T_NULL, + T_ARRAY, + T_OBJECT, + T_SELF, + T_PARENT, + T_FALSE, + T_TRUE, + T_CALLABLE, + T_STATIC, + ] + ) === true + && ($const === false || $this->lookAhead()['code'] !== T_EQUAL) + ) { + $varType .= $this->token['content']; + $this->advance(); + } + + // Check name. + if (($const === true && $this->token['code'] !== T_STRING) + || ($const === false && $this->token['code'] !== T_VARIABLE) + ) { + throw new \Exception('Expected variable or constant name.'); + } + + // Checking. + if ($this->pass === 2) { + if ($this->checkHasDocBlocks === true && $comment === null && $scope->type === 'classish') { + // Require comments for class variables and constants. + $this->file->addWarning( + 'PHPDoc variable or constant is not documented', + $this->filePtr, + 'PHPDocVarDocMissing' + ); + } else if ($comment !== null) { + // Check for misplaced tags. + $this->checkNo( + $comment, + [ + '@template', + '@property', + '@property-read', + '@property-write', + '@param', + '@return', + ] + ); + + if (isset($comment->tags['@var']) === false) { + $comment->tags['@var'] = []; + } + + // Missing var tag. + if ($this->checkHasTags === true && count($comment->tags['@var']) < 1) { + $this->file->addWarning( + 'PHPDoc variable missing @var tag', + $comment->ptr, + 'PHPDocVarTagMissing' + ); + } + + // Var type check and match. + $varParsed = $this->typesUtil->parseTypeAndName( + $scope, + $varType, + 0, + true + ); + + foreach ($comment->tags['@var'] as $docVar) { + $docVarParsed = $this->typesUtil->parseTypeAndName( + $scope, + $docVar->content, + 0, + false + ); + + if ($docVarParsed->type === null) { + $this->file->addError( + 'PHPDoc var type error: '.$docVarParsed->err, + $docVar->ptr, + 'PHPDocVarType' + ); + } else { + if ($this->checkTypeMatch === true + && $this->typesUtil->comparetypes($varParsed->type, $docVarParsed->type) === false + ) { + $this->file->addError( + 'PHPDoc var type mismatch', + $docVar->ptr, + 'PHPDocVarTypeMismatch' + ); + } + + if ($this->checkTypePhpFig === true && $docVarParsed->phpFig === false) { + $this->file->addError( + "PHPDoc var type doesn't conform to PHP-FIG PSR-5", + $docVar->ptr, + 'PHPDocVarTypePHPFIG' + ); + } + + if ($this->checkTypeStyle === true && $docVarParsed->fixed !== null) { + $fix = $this->file->addFixableError( + "PHPDoc var type doesn't conform to recommended style", + $docVar->ptr, + 'PHPDocVarTypeStyle' + ); + if ($fix === true) { + $this->fixCommentTag( + $docVar, + $docVarParsed->fixed + ); + } + } + }//end if + }//end foreach + }//end if + }//end if + + $this->advance(); + + if (in_array($this->token['code'], [T_EQUAL, T_COMMA, T_SEMICOLON, T_CLOSE_PARENTHESIS]) === false) { + throw new \Exception('Malformed variable or function declaration.'); + } + + }//end processVariable() + + + /** + * Process a possible variable comment. + * + * Variable comments can be used for variables defined in a variety of ways. + * If we find a PHPDoc var comment that's not attached to something we're looking for, + * we'll just check the type is well formed, and assume it's otherwise OK. + * + * @param ?(\stdClass&object{namespace: string, uses: array, templates: array, className: ?string, parentName: ?string, type: string, closer: ?int}) $scope We don't actually need the scope, because we're not doing a type comparison. + * @param ?(\stdClass&object{ptr: int, tags: array}) $comment PHPDoc block + * + * @return void + * @phpstan-impure + */ + protected function processPossVarComment($scope, $comment) + { + if ($this->pass === 2 && $comment !== null) { + $this->checkNo( + $comment, + [ + '@template', + '@property', + '@property-read', + '@property-write', + '@param', + '@return', + ] + ); + + // Check @var tags if any. + if (isset($comment->tags['@var']) === true) { + foreach ($comment->tags['@var'] as $docVar) { + $docVarParsed = $this->typesUtil->parseTypeAndName( + $scope, + $docVar->content, + 0, + false + ); + + if ($docVarParsed->type === null) { + $this->file->addError( + 'PHPDoc var type error: '.$docVarParsed->err, + $docVar->ptr, + 'PHPDocVarType' + ); + } else { + if ($this->checkTypePhpFig === true && $docVarParsed->phpFig === false) { + $this->file->addError( + "PHPDoc var type doesn't conform to PHP-FIG PSR-5", + $docVar->ptr, + 'PHPDocVarTypePHPFIG' + ); + } + + if ($this->checkTypeStyle === true && $docVarParsed->fixed !== null) { + $fix = $this->file->addFixableError( + "PHPDoc var type doesn't conform to recommended style", + $docVar->ptr, + 'PHPDocVarTypeStyle' + ); + if ($fix === true) { + $this->fixCommentTag( + $docVar, + $docVarParsed->fixed + ); + } + } + }//end if + }//end foreach + }//end if + }//end if + + }//end processPossVarComment() + + +}//end class diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.php b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.php new file mode 100644 index 0000000000..cfc6e7c7d9 --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.php @@ -0,0 +1,140 @@ + + * @copyright 2006-2015 Squiz Pty Ltd (ABN 77 084 670 600) + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + */ + +namespace PHP_CodeSniffer\Standards\Generic\Tests\Commenting; + +use PHP_CodeSniffer\Tests\Standards\AbstractSniffUnitTest; + +/** + * Unit test class for the PHPDoc Types sniff. + * + * @covers \PHP_CodeSniffer\Standards\Generic\Sniffs\Commenting\PHPDocTypesSniff + * @covers \PHP_CodeSniffer\Util\PHPDocTypesUtil + */ +final class PHPDocTypesUnitTest extends AbstractSniffUnitTest +{ + + + /** + * Returns the lines where errors should occur. + * + * The key of the array should represent the line number and the value + * should represent the number of errors that should occur on that line. + * + * @param string $testFile The name of the file being tested. + * + * @return array + */ + public function getErrorList($testFile='') + { + switch ($testFile) { + case 'PHPDocTypesUnitTest.wrong_core.inc': + return [ + 17 => 1, + 18 => 1, + 26 => 1, + 34 => 1, + 35 => 1, + 38 => 1, + 39 => 1, + 53 => 1, + 65 => 1, + 76 => 1, + 77 => 1, + 91 => 1, + ]; + case 'PHPDocTypesUnitTest.wrong_pass_splat.inc': + return [ + 24 => 1, + 25 => 1, + ]; + case 'PHPDocTypesUnitTest.wrong_php_parse.inc': + return [ + 134 => 1, + ]; + case 'PHPDocTypesUnitTest.wrong_tags_misplaced.inc': + return [ + 17 => 1, + 19 => 1, + 27 => 1, + 29 => 1, + 30 => 1, + 31 => 1, + 39 => 1, + 40 => 1, + 53 => 1, + 55 => 1, + 56 => 1, + 57 => 1, + ]; + case 'PHPDocTypesUnitTest.wrong_type_match.inc': + return [ + 23 => 1, + 31 => 1, + 33 => 1, + ]; + case 'PHPDocTypesUnitTest.wrong_type_parse.inc': + return [ + 24 => 1, + 37 => 1, + 50 => 1, + 57 => 1, + 64 => 1, + 71 => 1, + 78 => 1, + 84 => 1, + 91 => 1, + 98 => 1, + 105 => 1, + 112 => 1, + 119 => 1, + 126 => 1, + 133 => 1, + 140 => 1, + 147 => 1, + 154 => 1, + 161 => 1, + 168 => 1, + 175 => 1, + 183 => 1, + 196 => 1, + 203 => 1, + ]; + default: + return []; + }//end switch + + }//end getErrorList() + + + /** + * Returns the lines where warnings should occur. + * + * The key of the array should represent the line number and the value + * should represent the number of warnings that should occur on that line. + * + * @param string $testFile The name of the file being tested. + * + * @return array + */ + public function getWarningList($testFile='') + { + switch ($testFile) { + case 'PHPDocTypesUnitTest.wrong_core.inc': + return [ + 31 => 1, + ]; + default: + return []; + }//end switch + + }//end getWarningList() + + +}//end class diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_php.inc b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_php.inc new file mode 100644 index 0000000000..99c2ddea8a --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_php.inc @@ -0,0 +1,152 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +namespace PHP_CodeSniffer\Standards\Generic\Tests\Commenting; + +use stdClass as myStdClass, Exception; +use PHP_CodeSniffer\Standards\Generic\Tests\Commenting\ {PHPDocTypesUnitTest}; + +?> + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures { + + /** + * A collection of valid PHP for testing + */ + class PhpValid + { + + + /** + * Namespaces recognised + * + * @param \MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures\PhpValid $x + * + * @return void + */ + public function namespaces(PhpValid $x): void + { + + }//end namespaces() + + + }//end class + +} diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_type_non_php_fig.inc b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_type_non_php_fig.inc new file mode 100644 index 0000000000..3369daeead --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.right_type_non_php_fig.inc @@ -0,0 +1,650 @@ + + * @copyright 2023-2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +use stdClass as MyStdClass; + +/** + * A parent class + */ +class TypesValidParent +{ +}//end class + +/** + * An interface + */ +interface TypesValidInterface +{ +}//end interface + +/** + * A collection of valid types for testing + */ +class TypesValid extends TypesValidParent implements TypesValidInterface +{ + + /** + * @var array + */ + public const ARRAY_CONST = [ + 1 => 'one', + 2 => 'two', + ]; + + /** + * @var int + */ + public const INT_ONE = 1; + + /** + * @var int + */ + public const INT_TWO = 2; + + /** + * @var float + */ + public const FLOAT_1_0 = 1.0; + + /** + * @var float + */ + public const FLOAT_2_0 = 2.0; + + /** + * @var string + */ + public const STRING_HELLO = 'Hello'; + + /** + * @var string + */ + public const STRING_WORLD = 'World'; + + /** + * @var bool + */ + public const BOOL_FALSE = false; + + /** + * @var bool + */ + public const BOOL_TRUE = true; + + + /** + * Basic type equivalence + * + * @param bool $bool + * @param int $int + * @param float $float + * @param string $string + * @param object $object + * @param self $self + * @param parent $parent + * @param TypesValid $specificClass + * @param callable $callable + * + * @return void + */ + public function basicTypeEquivalence( + bool $bool, + int $int, + float $float, + string $string, + object $object, + self $self, + parent $parent, + TypesValid $specificClass, + callable $callable + ): void { + + }//end basicTypeEquivalence() + + + /** + * Types not supported natively (as of PHP 7.2) + * + * @param array $parameterisedArray + * @param resource $resource + * @param static $static + * @param iterable $parameterisedIterable + * @param array-key $arrayKey + * @param scalar $scalar + * @param mixed $mixed + * + * @return never + */ + public function nonNativeTypes($parameterisedArray, $resource, $static, $parameterisedIterable, + $arrayKey, $scalar, $mixed + ) { + throw new \Exception(); + + }//end nonNativeTypes() + + + /** + * Parameter modifiers + * + * @param object &$reference + * @param int ...$splat + * + * @return void + */ + public function parameterModifiers( + object &$reference, + int ...$splat + ): void { + + }//end parameterModifiers() + + + /** + * Boolean types + * + * @param bool|boolean $bool + * @param true|false $literal + * + * @return void + */ + public function booleanTypes(bool $bool, bool $literal): void + { + + }//end booleanTypes() + + + /** + * Integer types + * + * @param int|integer $int + * @param positive-int|negative-int|non-positive-int|non-negative-int $intRange1 + * @param int<0, 100>|int|int<50, max>|int<-100, max> $intRange2 + * @param 234|-234 $literal1 + * @param int-mask<1, 2, 4> $intMask1 + * + * @return void + */ + public function integerTypes(int $int, int $intRange1, int $intRange2, + int $literal1, int $intMask1 + ): void { + + }//end integerTypes() + + + /** + * Integer types complex + * + * @param 1_000|-1_000 $literal2 + * @param int-mask $intMask2 + * @param int-mask-of|int-mask-of> $intMask3 + * + * @return void + */ + public function integerTypesComplex(int $literal2, int $intMask2, int $intMask3): void + { + + }//end integerTypesComplex() + + + /** + * Float types + * + * @param float|double $float + * @param 1.0|-1.0 $literal + * + * @return void + */ + public function floatTypes(float $float, float $literal): void + { + + }//end floatTypes() + + + /** + * String types + * + * @param string $string + * @param class-string|class-string $classString1 + * @param callable-string|numeric-string|non-empty-string|non-falsy-string|truthy-string|literal-string $other + * @param 'foo'|'bar' $literal + * + * @return void + */ + public function stringTypes(string $string, string $classString1, string $other, string $literal): void + { + + }//end stringTypes() + + + /** + * String types complex + * + * @param '\'' $stringWithEscape + * + * @return void + */ + public function stringTypesComplex(string $stringWithEscape): void + { + + }//end stringTypesComplex() + + + /** + * Array types + * + * @param TypesValid[]|array|array $genArray1 + * @param non-empty-array|non-empty-array $genArray2 + * @param list|non-empty-list $list + * @param array{'foo': int, "bar": string}|array{'foo': int, "bar"?: string}|array{int, int} $shapes1 + * @param array{0: int, 1?: int}|array{foo: int, bar: string} $shapes2 + * + * @return void + */ + public function arrayTypes(array $genArray1, array $genArray2, array $list, + array $shapes1, array $shapes2 + ): void { + + }//end arrayTypes() + + + /** + * Array types complex + * + * @param array $genArray3 + * + * @return void + */ + public function arrayTypesComplex(array $genArray3): void + { + + }//end arrayTypesComplex() + + + /** + * Object types + * + * @param object $object + * @param object{'foo': int, "bar": string}|object{'foo': int, "bar"?: string} $shapes1 + * @param object{foo: int, bar?: string} $shapes2 + * @param TypesValid $class + * @param self|parent|static|$this $relative + * @param Traversable|Traversable $traversable1 + * @param \Closure|\Closure(int, int): string $closure + * + * @return void + */ + public function objectTypes(object $object, object $shapes1, object $shapes2, object $class, + object $relative, object $traversable1, object $closure + ): void { + + }//end objectTypes() + + + /** + * Object types complex + * + * @param Traversable<1|2, TypesValid|TypesValidInterface>|Traversable $traversable2 + * + * @return void + */ + public function objectTypesComplex(object $traversable2): void + { + + }//end objectTypesComplex() + + + /** + * Never type + * + * @return never|never-return|never-returns|no-return + */ + public function neverType() + { + throw new \Exception(); + + }//end neverType() + + + /** + * Null type + * + * @param null $standAloneNull + * @param ?int $explicitNullable + * @param ?int $implicitNullable + * + * @return void + */ + public function nullType( + $standAloneNull, + ?int $explicitNullable, + int $implicitNullable=null + ): void { + + }//end nullType() + + + /** + * User-defined type + * + * @param TypesValid|\TypesValid $class + * + * @return void + */ + public function userDefinedType(TypesValid $class): void + { + + }//end userDefinedType() + + + /** + * Callable types + * + * @param callable|callable(int, int): string|callable(int, int=): string $callable1 + * @param callable(int $foo, string $bar): void $callable2 + * @param callable(float ...$floats): (int|null)|callable(object&): ?int $callable3 + * @param \Closure|\Closure(int, int): string $closure + * @param callable-string $callableString + * + * @return void + */ + public function callableTypes(callable $callable1, callable $callable2, callable $callable3, + callable $closure, callable $callableString + ): void { + + }//end callableTypes() + + + /** + * Iterable types + * + * @param array $array + * @param iterable|iterable $iterable1 + * @param Traversable|Traversable $traversable1 + * + * @return void + */ + public function iterableTypes(iterable $array, iterable $iterable1, iterable $traversable1): void + { + + }//end iterableTypes() + + + /** + * Iterable types complex + * + * @param iterable<1|2, TypesValid>|iterable $iterable2 + * @param Traversable<1|2, TypesValid>|Traversable $traversable2 + * + * @return void + */ + public function iterableTypesComplex(iterable $iterable2, iterable $traversable2): void + { + + }//end iterableTypesComplex() + + + /** + * Key and value of + * + * @param key-of $keyOf1 + * @param value-of $valueOf1 + * + * @return void + */ + public function keyAndValueOf(int $keyOf1, string $valueOf1): void + { + + }//end keyAndValueOf() + + + /** + * Key and value of complex + * + * @param key-of> $keyOf2 + * @param value-of> $valueOf2 + * + * @return void + */ + public function keyAndValueOfComplex(int $keyOf2, string $valueOf2): void + { + + }//end keyAndValueOfComplex() + + + /** + * Conditional return types + * + * @param int $size + * + * @return ($size is positive-int ? non-empty-array : array) + */ + public function conditionalReturn(int $size): array + { + if ($size > 0) { + return array_fill(0, $size, 'entry'); + } else { + return []; + } + + }//end conditionalReturn() + + + /** + * Conditional return types complex 1 + * + * @param TypesValid::INT_*|TypesValid::STRING_* $x + * + * @return ($x is TypesValid::INT_* ? TypesValid::INT_* : TypesValid::STRING_*) + */ + public function conditionalReturnComplex1($x) + { + return $x; + + }//end conditionalReturnComplex1() + + + /** + * Conditional return types complex 2 + * + * @param 1|2|'Hello'|'World' $x + * + * @return ($x is 1|2 ? 1|2 : 'Hello'|'World') + */ + public function conditionalReturnComplex2($x) + { + return $x; + + }//end conditionalReturnComplex2() + + + /** + * Constant enumerations + * + * @param TypesValid::BOOL_FALSE|TypesValid::BOOL_TRUE|TypesValid::BOOL_* $bool + * @param TypesValid::INT_ONE $int1 + * @param TypesValid::INT_ONE|TypesValid::INT_TWO $int2 + * @param self::INT_* $int3 + * @param TypesValid::* $mixed + * @param TypesValid::FLOAT_1_0|TypesValid::FLOAT_2_0 $float + * @param TypesValid::STRING_HELLO $string + * @param TypesValid::ARRAY_CONST $array + * + * @return void + */ + public function constantEnumerations(bool $bool, int $int1, int $int2, int $int3, $mixed, + float $float, string $string, array $array + ): void { + + }//end constantEnumerations() + + + /** + * Basic structure + * + * @param ?int $nullable + * @param int|string $union + * @param TypesValid&object{additionalproperty: string} $intersection + * @param (int) $brackets + * @param int[] $arraySuffix + * + * @return void + */ + public function basicStructure( + ?int $nullable, + $union, + object $intersection, + int $brackets, + array $arraySuffix + ): void { + + }//end basicStructure() + + + /** + * Structure combinations + * + * @param int|float|string $multipleUnion + * @param TypesValid&object{additionalproperty: string}&\Traversable $multipleIntersection + * @param ((int)) $multipleBracket + * @param int[][] $multipleArray + * @param ?(int) $nullableBracket1 + * @param (?int) $nullableBracket2 + * @param ?int[] $nullableArray + * @param (int|float) $unionBracket1 + * @param int|(float) $unionBracket2 + * @param int|int[] $unionArray + * @param (TypesValid&object{additionalproperty: string}) $intersectionBracket1 + * @param TypesValid&(object{additionalproperty: string}) $intersectionBracket2 + * @param (int)[] $bracketArray1 + * @param (int[]) $bracketArray2 + * @param int|(TypesValid&object{additionalproperty: string}) $dnf + * + * @return void + */ + public function structureCombos( + $multipleUnion, + object $multipleIntersection, + int $multipleBracket, + array $multipleArray, + ?int $nullableBracket1, + ?int $nullableBracket2, + ?array $nullableArray, + $unionBracket1, + $unionBracket2, + $unionArray, + object $intersectionBracket1, + object $intersectionBracket2, + array $bracketArray1, + array $bracketArray2, + $dnf + ): void { + + }//end structureCombos() + + + /** + * DocType DNF vs Native DNF + * + * @param int|(TypesValidParent&TypesValidInterface) $p + * + * @return void + */ + public function dnfVsDnf((TypesValidInterface&TypesValidParent)|int $p): void + { + + }//end dnfVsDnf() + + + /** + * Inheritance + * + * @param TypesValid $basic + * @param self|static|$this $relative1 + * @param TypesValid $relative2 + * + * @return void + */ + public function inheritance( + TypesValidParent $basic, + parent $relative1, + parent $relative2 + ): void { + + }//end inheritance() + + + /** + * Template + * + * @param T $template + * + * @template T of int + * @return void + */ + public function template(int $template): void + { + + }//end template() + + + /** + * Use alias + * + * @param stdClass $use + * + * @return void + */ + public function uses(MyStdClass $use): void + { + + }//end uses() + + + /** + * Built-in classes with inheritance + * + * @param Traversable|Iterator|Generator|IteratorAggregate $traversable + * @param Iterator|Generator $iterator + * @param Throwable|Exception|Error $throwable + * @param Exception|ErrorException $exception + * @param Error|ArithmeticError|AssertionError|ParseError|TypeError $error + * @param ArithmeticError|DivisionByZeroError $arithmeticError + * + * @return void + */ + public function builtinClasses( + Traversable $traversable, Iterator $iterator, + Throwable $throwable, Exception $exception, Error $error, + ArithmeticError $arithmeticError + ): void { + + }//end builtinClasses() + + + /** + * SPL classes with inheritance (a few examples only) + * + * @param Iterator|SeekableIterator|ArrayIterator $iterator + * @param SeekableIterator|ArrayIterator $seekableIterator + * @param Countable|ArrayIterator $countable + * + * @return void + */ + public function splClasses( + Iterator $iterator, SeekableIterator $seekableIterator, Countable $countable + ): void { + + }//end splClasses() + + +}//end class diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_core.inc b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_core.inc new file mode 100644 index 0000000000..ebedbc09f3 --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_core.inc @@ -0,0 +1,93 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +/** + * A collection of invalid code for testing + * + * @property int< PHPDoc class property type missing or malformed + * @property int PHPDoc class property name missing or malformed + */ +class CoreErrors +{ + + /** + * PHPDoc var type missing or malformed + * + * @var @ + */ + public int $varTypeMalformed; + + + /** + * Function parameter issues + * + * @param int< $p1 PHPDoc function parameter type missing or malformed + * @param int PHPDoc function parameter name missing or malformed + * @param int $p4 PHPDoc function parameter order wrong + * @param int $p3 + * @param int $p3 PHPDoc function parameter repeated + * @param int $p5 PHPDoc function parameter doesn't exist + * + * @return void + */ + public function functionParameterIssues(int $p1, int $p2, int $p3, int $p4): void + { + + }//end functionParameterIssues() + + + /** + * PHPDoc multiple function @return tags--Put in one tag, seperated by vertical bars | + * + * @return int + * @return null + */ + public function multipleReturns(): ?int + { + return 0; + + }//end multipleReturns() + + + /** + * PHPDoc function return type missing or malformed + * + * @return + */ + public function returnMalformed(): void + { + + }//end returnMalformed() + + + /** + * Template issues + * + * @template @ PHPDoc template name missing or malformed + * @template T of @ PHPDoc template type missing or malformed + * @return void + */ + public function templateIssues(): void + { + + }//end templateIssues() + + +}//end class + +/** + * PHPDoc var type missing or malformed (not class var) + * + * @var @ + */ +$varTypeMalformed2 = 0; diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_pass_splat.inc b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_pass_splat.inc new file mode 100644 index 0000000000..4b300d9677 --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_pass_splat.inc @@ -0,0 +1,35 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +/** + * A function with mismatched pass by reference and splat for testing + */ +class PassSplatMismatch +{ + + + /** + * Function parameter issues + * + * @param integer ...$p1 PHPDoc function parameter pass by reference or splat mismatch + * @param integer $p2 PHPDoc function parameter pass by reference or splat mismatch + * + * @return void + */ + public function functionParameterIssues(int $p1, int &$p2): void + { + + }//end functionParameterIssues() + + +}//end class diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_php_parse.inc b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_php_parse.inc new file mode 100644 index 0000000000..e8711b5f7a --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_php_parse.inc @@ -0,0 +1,144 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +namespace TrailingBackslash\; + +// Malformed. +namespace @ + +use NoTrailingBackslash {Something +}; + +use TrailingBackslash\; + +// No bracket closer. +use x\ { ; + +// No content. +use x\ {}; + +// Malformed as clause. +use x as @; + +// No terminator. +use x @ + +/** + * Wrong place + * + * @var int + */ +public int $wrongPlace1; + + +/** + * Wrong places + * + * @return void + */ +function wrongPlaces(): void +{ + namespace ns; + use x; + + /** + * Wrong place + */ + class C + { + }//end class + + /** + * Wrong place + * + * @var int + */ + public int $wrongPlace2; + +}//end wrongPlaces() + + +/** + * No block + */ +class TypesInvalid + +/** + * No block close + */ +class C { + +/** + * Malformed class trait use + */ +class C { + use T { @ +} + + +/** + * No parameters + * + * @return void + */ +function f: void { + +}//end f() + + +/** + * No parameters close + * + * @return void + */ +function f( : void { + +}//end f() + + +/** + * No block + */ +function f(): void + +/** + * No block close + */ +function f(): void { + +/** + * Malformed declaration. + */ +public @ + +/** + * Unterminated variable + * + * @var int + */ +public int $v @ + + +/** + * Do we still reach here, and detect an error? + * + * @param string $p + * + * @return void + */ +function f(int $p): void +{ + +}//end f() + + +/** Unclosed Doc comment diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_tags_misplaced.inc b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_tags_misplaced.inc new file mode 100644 index 0000000000..25da603bae --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_tags_misplaced.inc @@ -0,0 +1,59 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +/** + * A collection of misplaced tags for testing + * + * @param integer $param + * + * @return integer + */ +class TagsMisplaced +{ + + /** + * PHPDoc var misplaced tags + * + * @param integer $param + * + * @property integer $prop + * @template T of integer + * @return integer + */ + public int $var1MisplacedTags; + + + /** + * Function misplaced tags + * + * @property integer $prop + * @var integer $var + */ + public function functionMisplacedTags(): void + { + + }//end functionMisplacedTags() + + +}//end class + +/** + * PHPDoc var (not class var) misplaced tags + * + * @param integer $param + * + * @property integer $prop + * @template T of integer + * @return integer + */ +$var2MisplacedTags = 0; diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_match.inc b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_match.inc new file mode 100644 index 0000000000..f012dab9b9 --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_match.inc @@ -0,0 +1,42 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +/** + * A collection of mismatched types for testing + */ +class TypeMismatch +{ + + /** + * PHPDoc var type mismatch + * + * @var string + */ + public int $varTypeMismatch; + + + /** + * PHPDoc function type mismatch + * + * @param string $p + * + * @return string + */ + public function funTypeMismatch(int $p): int + { + return 0; + + }//end funTypeMismatch() + + +}//end class diff --git a/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_parse.inc b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_parse.inc new file mode 100644 index 0000000000..cd40bc7c24 --- /dev/null +++ b/src/Standards/Generic/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_parse.inc @@ -0,0 +1,207 @@ + + * @copyright 2023-2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +/** + * A collection of invalid types for testing + */ +class TypesInvalid +{ + + + /** + * Expecting variable name, saw end + * + * @param int + * + * @return void + */ + public function expectingVarSawEnd(int $x): void + { + + }//end expectingVarSawEnd() + + + /** + * Expecting variable name, saw other (passes Psalm) + * + * @param int int + * + * @return void + */ + public function expectingVarSawOther(int $x): void + { + + }//end expectingVarSawOther() + + + /** + * Expecting type, saw end + * + * @var + */ + public $expectingTypeSawEnd; + + /** + * Expecting type, saw other + * + * @var $varname + */ + public $expectingTypeSawOther; + + /** + * Unterminated string (passes Psalm) + * + * @var " + */ + public $unterminatedString; + + /** + * Unterminated string with escaped quote (passes Psalm) + * + * @var "\" + */ + public $unterminatedStringWithEscapedQuote; + + /** + * String has escape with no following character (passes Psalm) + * + * @var "\*/ + public $stringHasEscapeWithNoFollowingChar; + + /** + * Non-DNF type (passes PHPStan) + * + * @var TypesInvalid&(a|b) + */ + public $nonDnfType; + + /** + * Invalid intersection + * + * @var integer&string + */ + public $invalidIntersection; + + /** + * Invalid int min + * + * @var int<0.0, 1> + */ + public $invalidIntMin; + + /** + * Invalid int max + * + * @var int<0, 1.0> + */ + public $invalidIntMax; + + /** + * Invalid int mask 1 + * + * @var int-mask<1.0, 2.0> + */ + public $invalidIntMask1; + + /** + * Invalid int mask 2 + * + * @var int-mask-of + */ + public $invalidIntMask2; + + /** + * Expecting class for class-string, saw end + * + * @var class-string< + */ + public $expectingClassForClassStringSawEnd; + + /** + * Expecting class for class-string, saw other + * + * @var class-string + */ + public $expectingClassForClassStringSawOther; + + /** + * List key + * + * @var list + */ + public $listKey; + + /** + * Invalid array key (passes Psalm) + * + * @var array + */ + public $invalidArrayKey; + + /** + * Non-empty-array shape + * + * @var non-empty-array{'a': int} + */ + public $nonEmptyArrayShape; + + /** + * Invalid object key (passes Psalm) + * + * @var object{0.0: int} + */ + public $invalidObjectKey; + + /** + * Can't get key of non-iterable + * + * @var key-of + */ + public $cantGetKeyOfNonIterable; + + /** + * Can't get value of non-iterable + * + * @var value-of + */ + public $cantGetValueOfNonIterable; + + + /** + * Class name has trailing slash + * + * @param TypesInvalid\ $x + * + * @return void + */ + public function classNameHasTrailingSlash(object $x): void + { + + }//end classNameHasTrailingSlash() + + + /** + * Expecting closing bracket, saw end. + * + * @var (TypesInvalid + */ + public $expectingClosingBracketSawEnd; + + /** + * Expecting closing bracket, saw other + * + * @var (TypesInvalid int + */ + public $expectingClosingBracketSawOther; + +}//end class diff --git a/src/Standards/PSR5/Docs/Commenting/PHPDocTypesStandard.xml b/src/Standards/PSR5/Docs/Commenting/PHPDocTypesStandard.xml new file mode 100644 index 0000000000..44ce803eb0 --- /dev/null +++ b/src/Standards/PSR5/Docs/Commenting/PHPDocTypesStandard.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + diff --git a/src/Standards/PSR5/Sniffs/Commenting/PHPDocTypesSniff.php b/src/Standards/PSR5/Sniffs/Commenting/PHPDocTypesSniff.php new file mode 100644 index 0000000000..6eba7b32a4 --- /dev/null +++ b/src/Standards/PSR5/Sniffs/Commenting/PHPDocTypesSniff.php @@ -0,0 +1,70 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +namespace PHP_CodeSniffer\Standards\PSR5\Sniffs\Commenting; + +use PHP_CodeSniffer\Standards\Generic\Sniffs\Commenting\PHPDocTypesSniff as SniffBase; + +/** + * Check PHPDoc Types for PHP-FIG PSR-5. + */ +class PHPDocTypesSniff extends SniffBase +{ + + /** + * Check named classes and functions, and class variables and constants are documented. + * + * @var boolean + */ + public $checkHasDocBlocks = true; + + /** + * Check doc blocks, if present, contain appropriate type tags. + * + * @var boolean + */ + public $checkHasTags = true; + + /** + * Check there are no misplaced type tags--doesn't check for misplaced var tags. + * + * @var boolean + */ + public $checkTagsNotMisplaced = true; + + /** + * Check PHPDoc types and native types match--isn't aware of class heirarchies from other files, or global constants. + * + * @var boolean + */ + public $checkTypeMatch = true; + + /** + * Check built-in types are lower case, and short forms are used. + * + * @var boolean + */ + public $checkTypeStyle = true; + + /** + * Check the types used conform to the PHP-FIG PSR-5 standard. + * + * @var boolean + */ + public $checkTypePhpFig = true; + + /** + * Check pass by reference and splat usage matches for param tags. + * + * @var boolean + */ + public $checkPassSplat = true; + +}//end class diff --git a/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.php b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.php new file mode 100644 index 0000000000..8176673bf6 --- /dev/null +++ b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.php @@ -0,0 +1,93 @@ + + * @copyright 2006-2015 Squiz Pty Ltd (ABN 77 084 670 600) + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + */ + +namespace PHP_CodeSniffer\Standards\PSR5\Tests\Commenting; + +use PHP_CodeSniffer\Tests\Standards\AbstractSniffUnitTest; + +/** + * Unit test class for the PHPDoc Types sniff. + * + * @covers \PHP_CodeSniffer\Standards\Generic\Sniffs\Commenting\PHPDocTypesSniff + * @covers \PHP_CodeSniffer\Util\PHPDocTypesUtil + */ +final class PHPDocTypesUnitTest extends AbstractSniffUnitTest +{ + + + /** + * Returns the lines where errors should occur. + * + * The key of the array should represent the line number and the value + * should represent the number of errors that should occur on that line. + * + * @param string $testFile The name of the file being tested. + * + * @return array + */ + public function getErrorList($testFile='') + { + switch ($testFile) { + case 'PHPDocTypesUnitTest.wrong_type_non_php_fig.inc': + return [ + 23 => 1, + 31 => 1, + 33 => 1, + 47 => 1, + ]; + case 'PHPDocTypesUnitTest.wrong_type_style.inc': + return [ + 17 => 1, + 25 => 1, + 32 => 1, + 39 => 1, + 48 => 1, + 50 => 1, + 51 => 1, + 65 => 1, + ]; + default: + return []; + }//end switch + + }//end getErrorList() + + + /** + * Returns the lines where warnings should occur. + * + * The key of the array should represent the line number and the value + * should represent the number of warnings that should occur on that line. + * + * @param string $testFile The name of the file being tested. + * + * @return array + */ + public function getWarningList($testFile='') + { + switch ($testFile) { + case 'PHPDocTypesUnitTest.warn_docs_missing.inc': + return [ + 17 => 1, + 21 => 1, + 27 => 1, + ]; + case 'PHPDocTypesUnitTest.warn_tags_missing.inc': + return [ + 22 => 2, + 31 => 1, + ]; + default: + return []; + }//end switch + + }//end getWarningList() + + +}//end class diff --git a/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.right_type.inc b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.right_type.inc new file mode 100644 index 0000000000..2fec9ee3fe --- /dev/null +++ b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.right_type.inc @@ -0,0 +1,317 @@ + + * @copyright 2023-2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +use stdClass as MyStdClass; + +/** + * A parent class + */ +class TypesValidParent +{ +}//end class + +/** + * An interface + */ +interface TypesValidInterface +{ +}//end interface + +/** + * A collection of valid types for testing + */ +class TypesValid extends TypesValidParent implements TypesValidInterface +{ + + + /** + * Basic type equivalence + * + * @param array $array + * @param bool $bool + * @param int $int + * @param float $float + * @param string $string + * @param object $object + * @param self $self + * @param iterable $iterable + * @param TypesValid $specificClass + * @param callable $callable + * + * @return void + */ + public function basicTypeEquivalence( + array $array, + bool $bool, + int $int, + float $float, + string $string, + object $object, + self $self, + iterable $iterable, + TypesValid $specificClass, + callable $callable + ): void { + + }//end basicTypeEquivalence() + + + /** + * Types not supported natively (as of PHP 7.2) + * + * @param resource $resource + * @param static $static + * @param mixed $mixed + * + * @return never + */ + public function nonNativeTypes($resource, $static, $mixed) + { + throw new \Exception(); + + }//end nonNativeTypes() + + + /** + * Parameter modifiers + * + * @param object &$reference + * @param int ...$splat + * + * @return void + */ + public function parameterModifiers( + object &$reference, + int ...$splat + ): void { + + }//end parameterModifiers() + + + /** + * Boolean types + * + * @param bool $bool + * @param true|false $literal + * + * @return void + */ + public function booleanTypes(bool $bool, bool $literal): void + { + + }//end booleanTypes() + + + /** + * Object types + * + * @param object $object + * @param TypesValid $class + * @param self|static|$this $relative + * @param Traversable $traversable + * @param \Closure $closure + * + * @return void + */ + public function objectTypes(object $object, object $class, + object $relative, object $traversable, object $closure + ): void { + + }//end objectTypes() + + + /** + * Null type + * + * @param null $standAloneNull + * @param int|null $explicitNullable + * @param int|null $implicitNullable + * + * @return void + */ + public function nullType( + $standAloneNull, + ?int $explicitNullable, + int $implicitNullable=null + ): void { + + }//end nullType() + + + /** + * User-defined type + * + * @param TypesValid|\TypesValid $class + * + * @return void + */ + public function userDefinedType(TypesValid $class): void + { + + }//end userDefinedType() + + + /** + * Callable types + * + * @param callable $callable + * @param \Closure $closure + * + * @return void + */ + public function callableTypes(callable $callable, callable $closure): void + { + + }//end callableTypes() + + + /** + * Iterable types + * + * @param array $array + * @param iterable $iterable + * @param Traversable $traversable + * + * @return void + */ + public function iterableTypes(iterable $array, iterable $iterable, iterable $traversable): void + { + + }//end iterableTypes() + + + /** + * Basic structure + * + * @param int|string $union + * @param TypesValid&object $intersection + * @param int[] $arraySuffix + * + * @return void + */ + public function basicStructure( + $union, + object $intersection, + array $arraySuffix + ): void { + + }//end basicStructure() + + + /** + * Structure combinations + * + * @param int|float|string $multipleUnion + * @param TypesValid&object&\Traversable $multipleIntersection + * @param int[][] $multipleArray + * @param int|int[] $unionArray + * @param (int)[] $bracketArray + * @param int|TypesValid&object $dnf + * + * @return void + */ + public function structureCombos( + $multipleUnion, + object $multipleIntersection, + array $multipleArray, + $unionArray, + array $bracketArray, + $dnf + ): void { + + }//end structureCombos() + + + /** + * Inheritance + * + * @param TypesValid $basic + * @param self|static|$this $relative1 + * @param TypesValid $relative2 + * + * @return void + */ + public function inheritance( + TypesValidParent $basic, + parent $relative1, + parent $relative2 + ): void { + + }//end inheritance() + + + /** + * Template + * + * @param T $template + * + * @template T of int + * @return void + */ + public function template(int $template): void + { + + }//end template() + + + /** + * Use alias + * + * @param stdClass $use + * + * @return void + */ + public function uses(MyStdClass $use): void + { + + }//end uses() + + + /** + * Built-in classes with inheritance + * + * @param Traversable|Iterator|Generator|IteratorAggregate $traversable + * @param Iterator|Generator $iterator + * @param Throwable|Exception|Error $throwable + * @param Exception|ErrorException $exception + * @param Error|ArithmeticError|AssertionError|ParseError|TypeError $error + * @param ArithmeticError|DivisionByZeroError $arithmeticError + * + * @return void + */ + public function builtinClasses( + Traversable $traversable, Iterator $iterator, + Throwable $throwable, Exception $exception, Error $error, + ArithmeticError $arithmeticError + ): void { + + }//end builtinClasses() + + + /** + * SPL classes with inheritance (a few examples only) + * + * @param Iterator|SeekableIterator|ArrayIterator $iterator + * @param SeekableIterator|ArrayIterator $seekableIterator + * @param Countable|ArrayIterator $countable + * + * @return void + */ + public function splClasses( + Iterator $iterator, SeekableIterator $seekableIterator, Countable $countable + ): void { + + }//end splClasses() + + +}//end class diff --git a/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.warn_docs_missing.inc b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.warn_docs_missing.inc new file mode 100644 index 0000000000..75cafa3b09 --- /dev/null +++ b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.warn_docs_missing.inc @@ -0,0 +1,29 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +declare(strict_types=1); + +// PHPDoc class is not documented. +class DocsMissing +{ + + // PHPDoc function is not documented. + public function funNotDoc(int $p): void + { + + }//end funNotDoc() + + // PHPDoc variable or constant is not documented. + public int $v1 = 0; + +}//end class diff --git a/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.warn_tags_missing.inc b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.warn_tags_missing.inc new file mode 100644 index 0000000000..3b28d7bfa2 --- /dev/null +++ b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.warn_tags_missing.inc @@ -0,0 +1,36 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +declare(strict_types=1); + +/** + * A collection code with missing PHPDoc tags for testing + */ +class TagsMissing +{ + + /** + * PHPDoc function parameter $p not documented and missing function @return tag + */ + public function funMissingParamRet(int $p): int + { + return $p; + + }//end funMissingParamRet() + + /** + * PHPDoc missing @var tag + */ + public int $v2 = 0; + +}//end class diff --git a/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_non_php_fig.inc b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_non_php_fig.inc new file mode 100644 index 0000000000..dd2a495f47 --- /dev/null +++ b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_non_php_fig.inc @@ -0,0 +1,49 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +/** + * A collection of types not conforming to PHP-FIG PSR-5 for testing + */ +class NonPhpFig +{ + + /** + * Non-PHP-FIG compliant variable + * + * @var ?int + */ + public ?int $v = 0; + + + /** + * Non-PHP-FIG compliant parameter and return + * + * @param ?int $p + * + * @return ?int + */ + public function f(?int $p): ?int + { + return $p; + + }//end f() + + +}//end class + +/** + * Non-PHP-FIG compliant variable (not class variable) + * + * @var ?int + */ +$v2 = 0; diff --git a/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_style.inc b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_style.inc new file mode 100644 index 0000000000..54145f2046 --- /dev/null +++ b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_style.inc @@ -0,0 +1,67 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +/** + * A collection of types not in recommended style for testing + * + * @property integer $p PHPDoc class property type doesn't conform to recommended style + */ +class WarnStyle +{ + + /** + * PHPDoc var type doesn't conform to recommended style + * + * @var integer + */ + public int $v1 = 0; + + /** + * Multiline type, no line break at end + * + * @var integer + * | boolean */ + public int|bool $v2 = 0; + + /** + * Multiline type, line break at end + * + * @var integer + * | boolean + */ + public int|bool $v3 = 0; + + + /** + * Function style + * + * @param Bool|T $p PHPDoc function parameter type doesn't conform to recommended style + * + * @return Int PHPDoc function return type doesn't conform to recommended style + * @template T of Int PHPDoc tempate type doesn't conform to recommended style + */ + public function funWrong($p): int + { + return 0; + + }//end funWrong() + + +}//end class + +/** + * PHPDoc var type doesn't conform to recommended style (not class var) + * + * @var Int + */ +$v4 = 0; diff --git a/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_style.inc.fixed b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_style.inc.fixed new file mode 100644 index 0000000000..168b620971 --- /dev/null +++ b/src/Standards/PSR5/Tests/Commenting/PHPDocTypesUnitTest.wrong_type_style.inc.fixed @@ -0,0 +1,67 @@ + + * @copyright 2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA 4.0 or later + */ + +/** + * A collection of types not in recommended style for testing + * + * @property int $p PHPDoc class property type doesn't conform to recommended style + */ +class WarnStyle +{ + + /** + * PHPDoc var type doesn't conform to recommended style + * + * @var int + */ + public int $v1 = 0; + + /** + * Multiline type, no line break at end + * + * @var int + * | bool */ + public int|bool $v2 = 0; + + /** + * Multiline type, line break at end + * + * @var int + * | bool + */ + public int|bool $v3 = 0; + + + /** + * Function style + * + * @param bool|T $p PHPDoc function parameter type doesn't conform to recommended style + * + * @return int PHPDoc function return type doesn't conform to recommended style + * @template T of int PHPDoc tempate type doesn't conform to recommended style + */ + public function funWrong($p): int + { + return 0; + + }//end funWrong() + + +}//end class + +/** + * PHPDoc var type doesn't conform to recommended style (not class var) + * + * @var int + */ +$v4 = 0; diff --git a/src/Standards/PSR5/ruleset.xml b/src/Standards/PSR5/ruleset.xml new file mode 100644 index 0000000000..7f3a3e25a6 --- /dev/null +++ b/src/Standards/PSR5/ruleset.xml @@ -0,0 +1,4 @@ + + + The PSR5 coding standard. + diff --git a/src/Util/PHPDocTypesUtil.php b/src/Util/PHPDocTypesUtil.php new file mode 100644 index 0000000000..f1f5e85b54 --- /dev/null +++ b/src/Util/PHPDocTypesUtil.php @@ -0,0 +1,1535 @@ + + * @copyright 2023-2024 Otago Polytechnic + * @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence + * CC BY-SA v4 or later + */ + +namespace PHP_CodeSniffer\Util; + +/** + * PHPDoc types utility + */ +class PHPDocTypesUtil +{ + + /** + * Predefined and SPL classes. + * + * @var array + */ + protected $library = [ + // Predefined general. + '\\ArrayAccess' => [], + '\\BackedEnum' => ['\\UnitEnum'], + '\\Closure' => ['callable'], + '\\Directory' => [], + '\\Fiber' => [], + '\\php_user_filter' => [], + '\\SensitiveParameterValue' => [], + '\\Serializable' => [], + '\\stdClass' => [], + '\\Stringable' => [], + '\\UnitEnum' => [], + '\\WeakReference' => [], + // Predefined iterables. + '\\Generator' => ['\\Iterator'], + '\\InternalIterator' => ['\\Iterator'], + '\\Iterator' => ['\\Traversable'], + '\\IteratorAggregate' => ['\\Traversable'], + '\\Traversable' => ['iterable'], + '\\WeakMap' => [ + '\\ArrayAccess', + '\\Countable', + '\\Iteratoraggregate', + ], + // Predefined throwables. + '\\ArithmeticError' => ['\\Error'], + '\\AssertionError' => ['\\Error'], + '\\CompileError' => ['\\Error'], + '\\DivisionByZeroError' => ['\\ArithmeticError'], + '\\Error' => ['\\Throwable'], + '\\ErrorException' => ['\\Exception'], + '\\Exception' => ['\\Throwable'], + '\\ParseError' => ['\\CompileError'], + '\\Throwable' => ['\\Stringable'], + '\\TypeError' => ['\\Error'], + // SPL Data structures. + '\\SplDoublyLinkedList' => [ + '\\Iterator', + '\\Countable', + '\\ArrayAccess', + '\\Serializable', + ], + '\\SplStack' => ['\\SplDoublyLinkedList'], + '\\SplQueue' => ['\\SplDoublyLinkedList'], + '\\SplHeap' => [ + '\\Iterator', + '\\Countable', + ], + '\\SplMaxHeap' => ['\\SplHeap'], + '\\SplMinHeap' => ['\\SplHeap'], + '\\SplPriorityQueue' => [ + '\\Iterator', + '\\Countable', + ], + '\\SplFixedArray' => [ + '\\IteratorAggregate', + '\\ArrayAccess', + '\\Countable', + '\\JsonSerializable', + ], + '\\Splobjectstorage' => [ + '\\Countable', + '\\Iterator', + '\\Serializable', + '\\Arrayaccess', + ], + // SPL iterators. + '\\AppendIterator' => ['\\IteratorIterator'], + '\\ArrayIterator' => [ + '\\SeekableIterator', + '\\ArrayAccess', + '\\Serializable', + '\\Countable', + ], + '\\CachingIterator' => [ + '\\IteratorIterator', + '\\ArrayAccess', + '\\Countable', + '\\Stringable', + ], + '\\CallbackFilterIterator' => ['\\FilterIterator'], + '\\DirectoryIterator' => [ + '\\SplFileInfo', + '\\SeekableIterator', + ], + '\\EmptyIterator' => ['\\Iterator'], + '\\FilesystemIterator' => ['\\DirectoryIterator'], + '\\FilterIterator' => ['\\IteratorIterator'], + '\\GlobalIterator' => [ + '\\FilesystemIterator', + '\\Countable', + ], + '\\InfiniteIterator' => ['\\IteratorIterator'], + '\\IteratorIterator' => ['\\OuterIterator'], + '\\LimitIterator' => ['\\IteratorIterator'], + '\\MultipleIterator' => ['\\Iterator'], + '\\NoRewindIterator' => ['\\IteratorIterator'], + '\\ParentIterator' => ['\\RecursiveFilterIterator'], + '\\RecursiveArrayIterator' => [ + '\\ArrayIterator', + '\\RecursiveIterator', + ], + '\\RecursiveCachingIterator' => [ + '\\CachingIterator', + '\\RecursiveIterator', + ], + '\\RecursiveCallbackFilterIterator' => [ + '\\CallbackFilterIterator', + '\\RecursiveIterator', + ], + '\\RecursiveDirectoryIterator' => [ + '\\FilesystemIterator', + '\\RecursiveIterator', + ], + '\\RecursiveFilterIterator' => [ + '\\FilterIterator', + '\\RecursiveIterator', + ], + '\\RecursiveIteratorIterator' => ['\\OuterIterator'], + '\\RecursiveRegexIterator' => [ + '\\RegexIterator', + '\\RecursiveIterator', + ], + '\\RecursiveTreeIterator' => ['\\RecursiveIteratorIterator'], + '\\RegexIterator' => ['\\FilterIterator'], + // SPL interfaces. + '\\Countable' => [], + '\\OuterIterator' => ['\\Iterator'], + '\\RecursiveIterator' => ['\\Iterator'], + '\\SeekableIterator' => ['\\Iterator'], + // SPL exceptions. + '\\BadFunctionCallException' => ['\\LogicException'], + '\\BadMethodCallException' => ['\\BadFunctionCallException'], + '\\DomainException' => ['\\LogicException'], + '\\InvalidArgumentException' => ['\\LogicException'], + '\\LengthException' => ['\\LogicException'], + '\\LogicException' => ['\\Exception'], + '\\OutOfBoundsException' => ['\\RuntimeException'], + '\\OutOfRangeException' => ['\\LogicException'], + '\\OverflowException' => ['\\RuntimeException'], + '\\RangeException' => ['\\RuntimeException'], + '\\RuntimeException' => ['\\Exception'], + '\\UnderflowException' => ['\\RuntimeException'], + '\\UnexpectedValueException' => ['\\RuntimeException'], + // SPL file handling. + '\\SplFileInfo' => ['\\Stringable'], + '\\SplFileObject' => [ + '\\SplFileInfo', + '\\RecursiveIterator', + '\\SeekableIterator', + ], + '\\SplTempFileObject' => ['\\SplFileObject'], + // SPL misc. + '\\ArrayObject' => [ + '\\IteratorAggregate', + '\\ArrayAccess', + '\\Serializable', + '\\Countable', + ], + '\\SplObserver' => [], + '\\SplSubject' => [], + ]; + + /** + * Inheritance heirarchy. + * + * @var array + */ + protected $artifacts; + + /** + * Scope. + * + * @var object{namespace: string, uses: string[], templates: string[], className: ?string, parentName: ?string} + */ + protected $scope; + + /** + * The text to be parsed. + * + * @var string + */ + protected $text = ''; + + /** + * Replacements. + * + * @var array + */ + protected $replacements = []; + + /** + * When we encounter an unknown type, what should we use? + * + * @var string + */ + protected $unknown = 'never'; + + /** + * Whether the type complies with the PHP-FIG PHPDoc standard. + * + * @var boolean + */ + protected $phpFig = true; + + /** + * Next tokens. + * + * @var object{startPos: non-negative-int, endPos: non-negative-int, text: ?string}[] + */ + protected $nexts = []; + + /** + * The next token. + * + * @var ?string + */ + protected $next = null; + + + /** + * Constructor + * + * @param array $artifacts Classish things + */ + public function __construct($artifacts=[]) + { + $this->artifacts = $artifacts; + + }//end __construct() + + + /** + * Parse a type and possibly variable name + * + * @param ?object{namespace: string, uses: string[], templates: string[], className: ?string, parentName: ?string} $scope the scope + * @param string $text the text to parse + * @param 0|1|2|3 $getWhat what to get 0=type only 1=also name 2=also modifiers (& ...) 3=also default + * @param bool $goWide if we can't determine the type, should we assume wide (for native type) or narrow (for PHPDoc)? + * + * @return object{ + * type: ?string, passSplat: string, name: ?string, rem: string, + * err: ?string, fixed: ?string, phpFig: bool + * } the simplified type, pass by reference & splat, variable name, remaining text, + * error message, fixed text, and whether PHP-FIG + */ + public function parseTypeAndName($scope, $text, $getWhat, $goWide) + { + + // Initialise variables. + if ($scope !== null) { + $this->scope = $scope; + } else { + $this->scope = (object) [ + 'namespace' => '', + 'uses' => [], + 'templates' => [], + 'className' => null, + 'parentName' => null, + ]; + } + + $this->text = $text; + $this->replacements = []; + if ($goWide === true) { + $this->unknown = 'mixed'; + } else { + $this->unknown = 'never'; + } + + $this->phpFig = true; + $this->nexts = []; + $this->next = $this->next(); + $err = null; + + // Try to parse type. + $savedNexts = $this->nexts; + try { + $type = $this->parseAnyType(); + if ($this->next !== null + && ctype_space(substr($this->text, ($this->nexts[0]->startPos - 1), 1)) === false + && in_array($this->next, [',', ';', ':', '.']) === false + ) { + // Code smell check. + throw new \Exception('No space after type.'); + } + } catch (\Exception $e) { + $this->nexts = $savedNexts; + $this->next = $this->next(); + $type = null; + $err = $e->getMessage(); + } + + // Try to parse pass by reference and splat. + $passSplat = ''; + if ($getWhat >= 2) { + if ($this->next === '&') { + $passSplat .= $this->parseToken('&'); + } + + if ($this->next === '...') { + $passSplat .= $this->parseToken('...'); + } + } + + // Try to parse name and default value. + if ($getWhat >= 1) { + $savedNexts = $this->nexts; + try { + if ($this->next === null || $this->next[0] !== '$') { + throw new \Exception("Expected variable name, saw \"{$this->next}\"."); + } + + $name = $this->parseToken(); + if ($this->next !== null + && ($getWhat < 3 || $this->next !== '=') + && ctype_space(substr($this->text, ($this->nexts[0]->startPos - 1), 1)) === false + && in_array($this->next, [',', ';', ':', '.']) === false + ) { + // Code smell check. + throw new \Exception('No space after variable name.'); + } + + // Implicit nullable. + if ($getWhat >= 3) { + if ($this->next === '=' + && strtolower(($this->next(1))) === 'null' + && strtolower(trim(substr($text, $this->nexts[1]->startPos))) === 'null' + && $type !== null && $type !== 'mixed' + ) { + $type = $type.'|null'; + } + } + } catch (\Exception $e) { + $this->nexts = $savedNexts; + $this->next = $this->next(); + $name = null; + }//end try + } else { + $name = null; + }//end if + + if ($type !== null) { + $fixed = $this->getFixed(); + } else { + $fixed = null; + } + + return (object) [ + 'type' => $type, + 'passSplat' => $passSplat, + 'name' => $name, + 'rem' => trim(substr($text, $this->nexts[0]->startPos)), + 'err' => $err, + 'fixed' => $fixed, + 'phpFig' => $this->phpFig, + ]; + + }//end parseTypeAndName() + + + /** + * Parse a template + * + * @param ?object{namespace: string, uses: string[], templates: string[], className: ?string, parentName: ?string} $scope the scope + * @param string $text the text to parse + * + * @return object{ + * type: ?string, name: ?string, rem: string, + * err: ?string, fixed: ?string, phpFig: bool + * } the simplified type, template name, remaining text, + * error message, fixed text, and whether PHP-FIG + */ + public function parseTemplate($scope, $text) + { + + // Initialise variables. + if ($scope !== null) { + $this->scope = $scope; + } else { + $this->scope = (object) [ + 'namespace' => '', + 'uses' => [], + 'templates' => [], + 'className' => null, + 'parentName' => null, + ]; + } + + $this->text = $text; + $this->replacements = []; + $this->unknown = 'never'; + $this->phpFig = true; + $this->nexts = []; + $this->next = $this->next(); + $err = null; + + // Try to parse template name. + $savedNexts = $this->nexts; + try { + if ($this->next === null || (ctype_alpha($this->next[0]) === false && $this->next[0] !== '_')) { + throw new \Exception("Expected template name, saw \"{$this->next}\"."); + } + + $name = $this->parseToken(); + if ($this->next !== null + && ctype_space(substr($this->text, ($this->nexts[0]->startPos - 1), 1)) === false + && in_array($this->next, [',', ';', ':', '.']) === false + ) { + // Code smell check. + throw new \Exception('No space after template name.'); + } + } catch (\Exception $e) { + $this->nexts = $savedNexts; + $this->next = $this->next(); + $name = null; + } + + if ($this->next === 'of' || $this->next === 'as') { + $this->parseToken(); + // Try to parse type. + $savedNexts = $this->nexts; + try { + $type = $this->parseAnyType(); + if ($this->next !== null + && ctype_space(substr($this->text, ($this->nexts[0]->startPos - 1), 1)) === false + && in_array($this->next, [',', ';', ':', '.']) === false + ) { + // Code smell check. + throw new \Exception('No space after type.'); + } + } catch (\Exception $e) { + $this->nexts = $savedNexts; + $this->next = $this->next(); + $type = null; + $err = $e->getMessage(); + } + } else { + $type = 'mixed'; + }//end if + + if ($type !== null) { + $fixed = $this->getFixed(); + } else { + $fixed = null; + } + + return (object) [ + 'type' => $type, + 'name' => $name, + 'rem' => trim(substr($text, $this->nexts[0]->startPos)), + 'err' => $err, + 'fixed' => $fixed, + 'phpFig' => $this->phpFig, + ]; + + }//end parseTemplate() + + + /** + * Compare types + * + * @param ?string $wideType the type that should be wider, e.g. PHP type + * @param ?string $narrowType the type that should be narrower, e.g. PHPDoc type + * + * @return bool whether $narrowType has the same or narrower scope as $wideType + */ + public function compareTypes($wideType, $narrowType) + { + if ($narrowType === null) { + return false; + } else if ($wideType === null || $wideType === 'mixed' || $narrowType === 'never') { + return true; + } + + $wideIntersections = explode('|', $wideType); + $narrowIntersections = explode('|', $narrowType); + + // We have to match all narrow intersections. + $haveAllIntersections = true; + foreach ($narrowIntersections as $narrowIntersection) { + $narrowSingles = explode('&', $narrowIntersection); + + // If the wide types are super types, that should match. + $narrowAdditions = []; + foreach ($narrowSingles as $narrowSingle) { + assert($narrowSingle !== ''); + $superTypes = $this->superTypes($narrowSingle); + $narrowAdditions = array_merge($narrowAdditions, $superTypes); + } + + $narrowSingles = array_merge($narrowSingles, $narrowAdditions); + sort($narrowSingles); + $narrowSingles = array_unique($narrowSingles); + + // We need to look in each wide intersection. + $haveThisIntersection = false; + foreach ($wideIntersections as $wideIntersection) { + $wideSingles = explode('&', $wideIntersection); + + // And find all parts of one of them. + $haveAllSingles = true; + foreach ($wideSingles as $wideSingle) { + if (in_array($wideSingle, $narrowSingles) === false) { + $haveAllSingles = false; + break; + } + } + + if ($haveAllSingles === true) { + $haveThisIntersection = true; + break; + } + } + + if ($haveThisIntersection === false) { + $haveAllIntersections = false; + break; + } + }//end foreach + + return $haveAllIntersections; + + }//end compareTypes() + + + /** + * Get super types + * + * @param string $baseType What type do we want the supers for? + * + * @return string[] super types + */ + protected function superTypes($baseType) + { + if (in_array($baseType, ['int', 'string']) === true) { + $superTypes = [ + 'array-key', + 'scaler', + ]; + } else if ($baseType === 'callable-string') { + $superTypes = [ + 'callable', + 'string', + 'array-key', + 'scalar', + ]; + } else if (in_array($baseType, ['array-key', 'float', 'bool']) === true) { + $superTypes = ['scalar']; + } else if ($baseType === 'array') { + $superTypes = ['iterable']; + } else if ($baseType === 'static') { + $superTypes = [ + 'self', + 'parent', + 'object', + ]; + } else if ($baseType === 'self') { + $superTypes = [ + 'parent', + 'object', + ]; + } else if ($baseType === 'parent') { + $superTypes = ['object']; + } else if (strpos($baseType, 'static(') === 0 || $baseType[0] === '\\') { + if (strpos($baseType, 'static(') === 0) { + $superTypes = [ + 'static', + 'self', + 'parent', + 'object', + ]; + $superTypeQueue = [substr($baseType, 7, -1)]; + $ignore = false; + } else { + $superTypes = ['object']; + $superTypeQueue = [$baseType]; + $ignore = true; + // We don't want to include the class itself, just super types of it. + } + + while (($superType = array_shift($superTypeQueue)) !== null) { + if (in_array($superType, $superTypes) === true) { + $ignore = false; + continue; + } + + if ($ignore === false) { + $superTypes[] = $superType; + } + + if (isset($this->library[$superType]) === true) { + $librarySupers = $this->library[$superType]; + } else { + $librarySupers = null; + } + + if (isset($this->artifacts[$superType]) === true) { + $superTypeObj = $this->artifacts[$superType]; + } else { + $superTypeObj = null; + } + + if ($librarySupers !== null) { + $superTypeQueue = array_merge($superTypeQueue, $librarySupers); + } else if ($superTypeObj !== null) { + if ($superTypeObj->extends !== null) { + $superTypeQueue[] = $superTypeObj->extends; + } + + if (count($superTypeObj->implements) > 0) { + foreach ($superTypeObj->implements as $implements) { + $superTypeQueue[] = $implements; + } + } + } else if ($ignore === false) { + $superTypes = array_merge($superTypes, $this->superTypes($superType)); + } + + $ignore = false; + }//end while + + $superTypes = array_unique($superTypes); + } else { + $superTypes = []; + }//end if + + return $superTypes; + + }//end superTypes() + + + /** + * Prefetch next token + * + * @param non-negative-int $lookAhead How far ahead is the token we want? + * + * @return ?string + * @phpstan-impure + */ + protected function next($lookAhead=0) + { + + // Fetch any more tokens we need. + while (count($this->nexts) < ($lookAhead + 1)) { + if (count($this->nexts) > 0) { + $startPos = end($this->nexts)->endPos; + } else { + $startPos = 0; + } + + $stringUnterminated = false; + + // Ignore whitespace. + while ($startPos < strlen($this->text) && ctype_space($this->text[$startPos]) === true) { + $startPos++; + } + + if ($startPos < strlen($this->text)) { + $firstChar = $this->text[$startPos]; + } else { + $firstChar = null; + } + + // Deal with different types of tokens. + if ($firstChar === null) { + // No more tokens. + $endPos = $startPos; + } else if (ctype_alpha($firstChar) === true || $firstChar === '_' || $firstChar === '$' || $firstChar === '\\' + || ord($firstChar) >= 0x7F + ) { + // Identifier token. + $endPos = $startPos; + do { + $endPos = ($endPos + 1); + if ($endPos < strlen($this->text)) { + $nextChar = $this->text[$endPos]; + } else { + $nextChar = null; + } + } while ($nextChar !== null && (ctype_alnum($nextChar) === true || $nextChar === '_' + || ord($nextChar) >= 0x7F + || ($firstChar !== '$' && ($nextChar === '-' || $nextChar === '\\'))) + ); + } else if (ctype_digit($firstChar) === true + || ($firstChar === '-' && strlen($this->text) >= ($startPos + 2) && ctype_digit($this->text[($startPos + 1)]) === true) + ) { + // Number token. + $nextChar = $firstChar; + $havePoint = false; + $endPos = $startPos; + do { + $havePoint = $havePoint || $nextChar === '.'; + $endPos = ($endPos + 1); + if ($endPos < strlen($this->text)) { + $nextChar = $this->text[$endPos]; + } else { + $nextChar = null; + } + } while ($nextChar !== null && (ctype_digit($nextChar) === true || ($nextChar === '.' && $havePoint === false) || $nextChar === '_')); + } else if ($firstChar === '"' || $firstChar === "'") { + // String token. + $endPos = ($startPos + 1); + if ($endPos < strlen($this->text)) { + $nextChar = $this->text[$endPos]; + } else { + $nextChar = null; + } + + while ($nextChar !== $firstChar && $nextChar !== null) { + // There may be unterminated strings. + if ($nextChar === '\\' && strlen($this->text) >= ($endPos + 2)) { + $endPos = ($endPos + 2); + } else { + $endPos++; + } + + if ($endPos < strlen($this->text)) { + $nextChar = $this->text[$endPos]; + } else { + $nextChar = null; + } + } + + if ($nextChar !== null) { + $endPos++; + } else { + $stringUnterminated = true; + } + } else if (strlen($this->text) >= ($startPos + 3) && substr($this->text, $startPos, 3) === '...') { + // Splat. + $endPos = ($startPos + 3); + } else if (strlen($this->text) >= ($startPos + 2) && substr($this->text, $startPos, 2) === '::') { + // Scope resolution operator. + $endPos = ($startPos + 2); + } else { + // Other symbol token. + $endPos = ($startPos + 1); + }//end if + + // Store token. + $next = substr($this->text, $startPos, ($endPos - $startPos)); + if ($stringUnterminated === true) { + $next = '[unterminated string]'; + } else if ($next === false || $next === '') { + $next = null; + } + + $this->nexts[] = (object) [ + 'startPos' => $startPos, + 'endPos' => $endPos, + 'text' => $next, + ]; + }//end while + + // Return the needed token. + return $this->nexts[$lookAhead]->text; + + }//end next() + + + /** + * Fetch the next token + * + * @param ?string $expect the expected text, or null for any + * + * @return string + * @phpstan-impure + */ + protected function parseToken($expect=null) + { + + $next = $this->next; + + // Check we have the expected token. + if ($next === null) { + throw new \Exception('Unexpected end.'); + } else if ($expect !== null && strtolower($next) !== strtolower($expect)) { + throw new \Exception("Expected \"{$expect}\", saw \"{$next}\"."); + } + + // Prefetch next token. + $this->next(1); + + // Return consumed token. + array_shift($this->nexts); + $this->next = $this->next(); + return $next; + + }//end parseToken() + + + /** + * Correct the next token + * + * @param string $correct the corrected text + * + * @return void + * @phpstan-impure + */ + protected function correctToken($correct) + { + if ($correct !== $this->nexts[0]->text) { + $this->replacements[] = (object) [ + 'pos' => $this->nexts[0]->startPos, + 'len' => strlen($this->nexts[0]->text), + 'replacement' => $correct, + ]; + } + + }//end correctToken() + + + /** + * Get the corrected text, or null if no change + * + * @return ?string + */ + protected function getFixed() + { + if (count($this->replacements) === 0) { + return null; + } + + $fixedText = $this->text; + foreach (array_reverse($this->replacements) as $fix) { + $fixedText = substr($fixedText, 0, $fix->pos).$fix->replacement.substr($fixedText, ($fix->pos + $fix->len)); + } + + return $fixedText; + + }//end getFixed() + + + /** + * Parse a list of types seperated by | and/or &, single nullable type, or conditional return type + * + * @param bool $inBrackets are we immediately inside brackets? + * + * @return string the simplified type + * @phpstan-impure + */ + protected function parseAnyType($inBrackets=false) + { + + if ($inBrackets === true && $this->next !== null && $this->next[0] === '$' && $this->next(1) === 'is') { + // Conditional return type. + $this->phpFig = false; + $this->parseToken(); + $this->parseToken('is'); + $this->parseAnyType(); + $this->parseToken('?'); + $firstType = $this->parseAnyType(); + $this->parseToken(':'); + $secondType = $this->parseAnyType(); + $unionTypes = array_merge(explode('|', $firstType), explode('|', $secondType)); + } else if ($this->next === '?') { + // Single nullable type. + $this->phpFig = false; + $this->parseToken('?'); + $unionTypes = explode('|', $this->parseSingleType()); + $unionTypes[] = 'null'; + } else { + // Union list. + $unionTypes = []; + do { + // Intersection list. + $unionInstead = null; + $intersectionTypes = []; + do { + $singleType = $this->parseSingleType(); + if (strpos($singleType, '|') !== false) { + $intersectionTypes[] = $this->unknown; + $unionInstead = $singleType; + } else { + $intersectionTypes = array_merge($intersectionTypes, explode('&', $singleType)); + } + + // We have to figure out whether a & is for intersection or pass by reference. + $nextNext = $this->next(1); + $haveMoreIntersections = $this->next === '&' + && !(in_array($nextNext, ['...', '=', ',', ')', null]) + || ($nextNext !== null && $nextNext[0] === '$')); + if ($haveMoreIntersections === true) { + $this->parseToken('&'); + } + } while ($haveMoreIntersections === true); + if (count($intersectionTypes) > 1 && $unionInstead !== null) { + throw new \Exception('Non-DNF.'); + } else if (count($intersectionTypes) <= 1 && $unionInstead !== null) { + $unionTypes = array_merge($unionTypes, explode('|', $unionInstead)); + } else { + // Tidy and store intersection list. + if (count($intersectionTypes) > 1) { + foreach ($intersectionTypes as $intersectionType) { + assert($intersectionType !== ''); + $superTypes = $this->superTypes($intersectionType); + if (in_array($intersectionType, ['object', 'iterable', 'callable']) === false + && in_array('object', $superTypes) === false + ) { + throw new \Exception('Intersection can only be used with objects.'); + } + + foreach ($superTypes as $superType) { + $superPos = array_search($superType, $intersectionTypes); + if ($superPos !== false) { + unset($intersectionTypes[$superPos]); + } + } + } + + sort($intersectionTypes); + $intersectionTypes = array_unique($intersectionTypes); + $neverPos = array_search('never', $intersectionTypes); + if ($neverPos !== false) { + $intersectionTypes = ['never']; + } + + $mixedPos = array_search('mixed', $intersectionTypes); + if ($mixedPos !== false && count($intersectionTypes) > 1) { + unset($intersectionTypes[$mixedPos]); + } + }//end if + + array_push($unionTypes, implode('&', $intersectionTypes)); + }//end if + // Check for more union items. + $haveMoreUnions = $this->next === '|'; + if ($haveMoreUnions === true) { + $this->parseToken('|'); + } + } while ($haveMoreUnions === true); + }//end if + + // Tidy and return union list. + if (count($unionTypes) > 1) { + if (in_array('int', $unionTypes) === true && in_array('string', $unionTypes) === true) { + $unionTypes[] = 'array-key'; + } + + if (in_array('bool', $unionTypes) === true && in_array('float', $unionTypes) === true && in_array('array-key', $unionTypes) === true) { + $unionTypes[] = 'scalar'; + } + + if (in_array('\\Traversable', $unionTypes) === true && in_array('array', $unionTypes) === true) { + $unionTypes[] = 'iterable'; + } + + sort($unionTypes); + $unionTypes = array_unique($unionTypes); + $mixedPos = array_search('mixed', $unionTypes); + if ($mixedPos !== false) { + $unionTypes = ['mixed']; + } + + $neverPos = array_search('never', $unionTypes); + if ($neverPos !== false && count($unionTypes) > 1) { + unset($unionTypes[$neverPos]); + } + + foreach ($unionTypes as $key1 => $unionType1) { + assert($unionType1 !== ''); + foreach ($unionTypes as $key2 => $unionType2) { + assert($unionType2 !== ''); + if ($key2 !== $key1 && $this->compareTypes($unionType1, $unionType2) === true) { + unset($unionTypes[$key2]); + } + } + } + }//end if + + $type = implode('|', $unionTypes); + assert($type !== ''); + return $type; + + }//end parseAnyType() + + + /** + * Parse a single type, possibly array type + * + * @return string the simplified type + * @phpstan-impure + */ + protected function parseSingleType() + { + $hasBrackets = false; + if ($this->next === '(') { + $hasBrackets = true; + $this->parseToken('('); + $type = $this->parseAnyType(true); + $this->parseToken(')'); + } else { + $type = $this->parseBasicType(); + } + + if ($hasBrackets === true && $this->next !== '[') { + $this->phpFig = false; + } + + while ($this->next === '[' && $this->next(1) === ']') { + // Array suffix. + $this->parseToken('['); + $this->parseToken(']'); + $type = 'array'; + } + + return $type; + + }//end parseSingleType() + + + /** + * Parse a basic type + * + * @return string the simplified type + * @phpstan-impure + */ + protected function parseBasicType() + { + + $next = $this->next; + if ($next === null) { + throw new \Exception('Expected type, saw end.'); + } + + $lowerNext = strtolower($next); + $nextChar = $next[0]; + + if (in_array($lowerNext, ['bool', 'boolean', 'true', 'false']) === true) { + // Bool. + if ($lowerNext === 'boolean') { + $this->correctToken('bool'); + } else { + $this->correctToken($lowerNext); + } + + $this->parseToken(); + $type = 'bool'; + } else if (in_array( + $lowerNext, + [ + 'int', + 'integer', + 'positive-int', + 'negative-int', + 'non-positive-int', + 'non-negative-int', + 'int-mask', + 'int-mask-of', + ] + ) === true + || ((ctype_digit($nextChar) === true || $nextChar === '-') && strpos($next, '.') === false) + ) { + // Int. + if (in_array($lowerNext, ['int', 'integer']) === false) { + $this->phpFig = false; + } + + if ($lowerNext === 'integer') { + $this->correctToken('int'); + } else { + $this->correctToken($lowerNext); + } + + $intType = strtolower($this->parseToken()); + if ($intType === 'int' && $this->next === '<') { + // Integer range. + $this->phpFig = false; + $this->parseToken('<'); + $next = $this->next; + if ($next === null + || (strtolower($next) !== 'min' + && ((ctype_digit($next[0]) === false && $next[0] !== '-') || strpos($next, '.') !== false)) + ) { + throw new \Exception("Expected int min, saw \"{$next}\"."); + } + + $this->parseToken(); + $this->parseToken(','); + $next = $this->next; + if ($next === null + || (strtolower($next) !== 'max' + && ((ctype_digit($next[0]) === false && $next[0] !== '-') || strpos($next, '.') !== false)) + ) { + throw new \Exception("Expected int max, saw \"{$next}\"."); + } + + $this->parseToken(); + $this->parseToken('>'); + } else if ($intType === 'int-mask') { + // Integer mask. + $this->parseToken('<'); + do { + $mask = $this->parseBasicType(); + if ($this->compareTypes('int', $mask) === false) { + throw new \Exception('Invalid int mask.'); + } + + $haveSeperator = $this->next === ','; + if ($haveSeperator === true) { + $this->parseToken(','); + } + } while ($haveSeperator === true); + $this->parseToken('>'); + } else if ($intType === 'int-mask-of') { + // Integer mask of. + $this->parseToken('<'); + $mask = $this->parseBasicType(); + if ($this->compareTypes('int', $mask) === false) { + throw new \Exception('Invalid int mask of.'); + } + + $this->parseToken('>'); + }//end if + + $type = 'int'; + } else if (in_array($lowerNext, ['float', 'double']) === true + || ((ctype_digit($nextChar) === true || $nextChar === '-') && strpos($next, '.') !== false) + ) { + // Float. + if (in_array($lowerNext, ['float', 'double']) === false) { + $this->phpFig = false; + } + + if ($lowerNext === 'double') { + $this->correctToken('float'); + } else { + $this->correctToken($lowerNext); + } + + $this->parseToken(); + $type = 'float'; + } else if (in_array( + $lowerNext, + [ + 'string', + 'class-string', + 'numeric-string', + 'literal-string', + 'non-empty-string', + 'non-falsy-string', + 'truthy-string', + ] + ) === true + || $nextChar === '"' || $nextChar === "'" + ) { + // String. + if ($lowerNext !== 'string') { + $this->phpFig = false; + } + + if ($nextChar !== '"' && $nextChar !== "'") { + $this->correctToken($lowerNext); + } + + $strType = strtolower($this->parseToken()); + if ($strType === 'class-string' && $this->next === '<') { + $this->parseToken('<'); + $objectType = $this->parseBasicType(); + if ($this->compareTypes('object', $objectType) === false) { + throw new \Exception("Class-string type isn't class."); + } + + $this->parseToken('>'); + } + + $type = 'string'; + } else if ($lowerNext === 'callable-string') { + // Callable-string. + $this->phpFig = false; + $this->correctToken($lowerNext); + $this->parseToken('callable-string'); + $type = 'callable-string'; + } else if (in_array($lowerNext, ['array', 'non-empty-array', 'list', 'non-empty-list']) === true) { + // Array. + if ($lowerNext !== 'array') { + $this->phpFig = false; + } + + $this->correctToken($lowerNext); + $arrayType = strtolower($this->parseToken()); + if ($this->next === '<') { + // Typed array. + $this->phpFig = false; + $this->parseToken('<'); + $firstType = $this->parseAnyType(); + if ($this->next === ',') { + if (in_array($arrayType, ['list', 'non-empty-list']) === true) { + throw new \Exception('Lists cannot have keys specified.'); + } + + $key = $firstType; + if ($this->compareTypes('array-key', $key) === false) { + throw new \Exception('Invalid array key.'); + } + + $this->parseToken(','); + $value = $this->parseAnyType(); + } else { + $key = null; + $value = $firstType; + } + + $this->parseToken('>'); + } else if ($this->next === '{') { + // Array shape. + $this->phpFig = false; + if (in_array($arrayType, ['non-empty-array', 'non-empty-list']) === true) { + throw new \Exception('Non-empty-arrays cannot have shapes.'); + } + + $this->parseToken('{'); + do { + $next = $this->next; + if ($next !== null + && (ctype_alpha($next) === true || $next[0] === '_' || $next[0] === "'" || $next[0] === '"' + || ((ctype_digit($next[0]) === true || $next[0] === '-') && strpos($next, '.') === false)) + && ($this->next(1) === ':' || ($this->next(1) === '?' && $this->next(2) === ':')) + ) { + $this->parseToken(); + if ($this->next === '?') { + $this->parseToken('?'); + } + + $this->parseToken(':'); + } + + $this->parseAnyType(); + $haveComma = $this->next === ','; + if ($haveComma === true) { + $this->parseToken(','); + } + } while ($haveComma === true); + $this->parseToken('}'); + }//end if + + $type = 'array'; + } else if ($lowerNext === 'object') { + // Object. + $this->correctToken($lowerNext); + $this->parseToken('object'); + if ($this->next === '{') { + // Object shape. + $this->phpFig = false; + $this->parseToken('{'); + do { + $next = $this->next; + if ($next === null + || (ctype_alpha($next) === false && $next[0] !== '_' && $next[0] !== "'" && $next[0] !== '"') + ) { + throw new \Exception('Invalid object key.'); + } + + $this->parseToken(); + if ($this->next === '?') { + $this->parseToken('?'); + } + + $this->parseToken(':'); + $this->parseAnyType(); + $haveComma = $this->next === ','; + if ($haveComma === true) { + $this->parseToken(','); + } + } while ($haveComma === true); + $this->parseToken('}'); + }//end if + + $type = 'object'; + } else if ($lowerNext === 'resource') { + // Resource. + $this->correctToken($lowerNext); + $this->parseToken('resource'); + $type = 'resource'; + } else if (in_array($lowerNext, ['never', 'never-return', 'never-returns', 'no-return']) === true) { + // Never. + $this->correctToken('never'); + $this->parseToken(); + $type = 'never'; + } else if ($lowerNext === 'null') { + // Null. + $this->correctToken($lowerNext); + $this->parseToken('null'); + $type = 'null'; + } else if ($lowerNext === 'void') { + // Void. + $this->correctToken($lowerNext); + $this->parseToken('void'); + $type = 'void'; + } else if ($lowerNext === 'self') { + // Self. + $this->correctToken($lowerNext); + $this->parseToken('self'); + if ($this->scope->className !== null) { + $type = $this->scope->className; + } else { + $type = 'self'; + } + } else if ($lowerNext === 'parent') { + // Parent. + $this->phpFig = false; + $this->correctToken($lowerNext); + $this->parseToken('parent'); + if ($this->scope->parentName !== null) { + $type = $this->scope->parentName; + } else { + $type = 'parent'; + } + } else if (in_array($lowerNext, ['static', '$this']) === true) { + // Static. + $this->correctToken($lowerNext); + $this->parseToken(); + if ($this->scope->className !== null) { + $type = "static({$this->scope->className})"; + } else { + $type = 'static'; + } + } else if ($lowerNext === 'callable' + || $next === '\\Closure' || ($next === 'Closure' && $this->scope->namespace === '') + ) { + // Callable. + if ($lowerNext === 'callable') { + $this->correctToken($lowerNext); + } + + $callableType = $this->parseToken(); + if ($this->next === '(') { + $this->phpFig = false; + $this->parseToken('('); + while ($this->next !== ')') { + $this->parseAnyType(); + if ($this->next === '&') { + $this->parseToken('&'); + } + + if ($this->next === '...') { + $this->parseToken('...'); + } + + if ($this->next === '=') { + $this->parseToken('='); + } + + if ($this->next !== null) { + $nextChar = $this->next[0]; + } else { + $nextChar = null; + } + + if ($nextChar === '$') { + $this->parseToken(); + } + + if ($this->next !== ')') { + $this->parseToken(','); + } + }//end while + + $this->parseToken(')'); + $this->parseToken(':'); + if ($this->next === '?') { + $this->parseAnyType(); + } else { + $this->parseSingleType(); + } + }//end if + + if (strtolower($callableType) === 'callable') { + $type = 'callable'; + } else { + $type = '\\Closure'; + } + } else if ($lowerNext === 'mixed') { + // Mixed. + $this->correctToken($lowerNext); + $this->parseToken('mixed'); + $type = 'mixed'; + } else if ($lowerNext === 'iterable') { + // Iterable (Traversable|array). + $this->correctToken($lowerNext); + $this->parseToken('iterable'); + if ($this->next === '<') { + $this->phpFig = false; + $this->parseToken('<'); + $firstType = $this->parseAnyType(); + if ($this->next === ',') { + $key = $firstType; + $this->parseToken(','); + $value = $this->parseAnyType(); + } else { + $key = null; + $value = $firstType; + } + + $this->parseToken('>'); + } + + $type = 'iterable'; + } else if ($lowerNext === 'array-key') { + // Array-key (int|string). + $this->phpFig = false; + $this->correctToken($lowerNext); + $this->parseToken('array-key'); + $type = 'array-key'; + } else if ($lowerNext === 'scalar') { + // Scalar can be (bool|int|float|string). + $this->phpFig = false; + $this->correctToken($lowerNext); + $this->parseToken('scalar'); + $type = 'scalar'; + } else if ($lowerNext === 'key-of') { + // Key-of. + $this->phpFig = false; + $this->correctToken($lowerNext); + $this->parseToken('key-of'); + $this->parseToken('<'); + $iterable = $this->parseAnyType(); + if ($this->compareTypes('iterable', $iterable) === false && $this->compareTypes('object', $iterable) === false) { + throw new \Exception("Can't get key of non-iterable."); + } + + $this->parseToken('>'); + $type = $this->unknown; + } else if ($lowerNext === 'value-of') { + // Value-of. + $this->phpFig = false; + $this->correctToken($lowerNext); + $this->parseToken('value-of'); + $this->parseToken('<'); + $iterable = $this->parseAnyType(); + if ($this->compareTypes('iterable', $iterable) === false && $this->compareTypes('object', $iterable) === false) { + throw new \Exception("Can't get value of non-iterable."); + } + + $this->parseToken('>'); + $type = $this->unknown; + } else if ((ctype_alpha($next[0]) === true || $next[0] === '_' || $next[0] === '\\') + && strpos($next, '-') === false && strpos($next, '\\\\') === false + ) { + // Class name. + $type = $this->parseToken(); + if (strrpos($type, '\\') === (strlen($type) - 1)) { + throw new \Exception('Class name has trailing back slash.'); + } + + if ($type[0] !== '\\') { + if (array_key_exists($type, $this->scope->uses) === true) { + $type = $this->scope->uses[$type]; + } else if (array_key_exists($type, $this->scope->templates) === true) { + $type = $this->scope->templates[$type]; + } else { + $type = $this->scope->namespace.'\\'.$type; + } + + assert($type !== ''); + } + } else { + throw new \Exception("Expected type, saw \"{$this->next}\"."); + }//end if + + // Suffixes. We can't embed these in the class name section, because they could apply to relative classes. + if ($this->next === '<' + && (in_array('object', $this->superTypes($type)) === true) + ) { + // Generics. + $this->phpFig = false; + $this->parseToken('<'); + $more = false; + do { + $this->parseAnyType(); + $more = ($this->next === ','); + if ($more === true) { + $this->parseToken(','); + } + } while ($more === true); + $this->parseToken('>'); + } else if ($this->next === '::' + && (in_array('object', $this->superTypes($type)) === true) + ) { + // Class constant. + $this->phpFig = false; + $this->parseToken('::'); + if ($this->next === null) { + $nextChar = null; + } else { + $nextChar = $this->next[0]; + } + + $haveConstantName = $nextChar !== null && (ctype_alpha($nextChar) || $nextChar === '_'); + if ($haveConstantName === true) { + $this->parseToken(); + } + + if ($this->next === '*' || $haveConstantName === false) { + $this->parseToken('*'); + } + + $type = $this->unknown; + }//end if + + return $type; + + }//end parseBasicType() + + +}//end class