diff --git a/moodle/Sniffs/Commenting/PHPDocTypesSniff.php b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php new file mode 100644 index 0000000..fbdad43 --- /dev/null +++ b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php @@ -0,0 +1,1491 @@ +. + +/** + * Check PHPDoc Types. + * + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +declare(strict_types=1); + +namespace MoodleHQ\MoodleCS\moodle\Sniffs\Commenting; + +use PHP_CodeSniffer\Sniffs\Sniff; +use PHP_CodeSniffer\Files\File; +use PHP_CodeSniffer\Util\Tokens; +use MoodleHQ\MoodleCS\moodle\Util\PHPDocTypeParser; + +/** + * Check PHPDoc Types. + */ +class PHPDocTypesSniff implements Sniff +{ + public const DEBUG_MODE = false; + public const CHECK_HAS_DOCS = false; + public const CHECK_NOT_COMPLEX = true; + + /** @var ?File the current file */ + protected ?File $file = null; + + /** @var array{ + * 'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int, + * 'parenthesis_opener'?: int, 'parenthesis_closer'?: int, 'attribute_closer'?: int + * }[] file tokens */ + protected array $tokens = []; + + /** @var array + * classish things: classes, interfaces, traits, and enums */ + protected array $artifacts = []; + + /** @var ?PHPDocTypeParser for parsing and comparing types */ + protected ?PHPDocTypeParser $typeparser = null; + + /** @var 1|2 pass 1 for gathering artifact/classish info, 2 for checking */ + protected int $pass = 1; + + /** @var int current token pointer in the file */ + protected int $fileptr = 0; + + /** @var ?( + * \stdClass&object{ + * ptr: int, + * tags: array + * } + * ) PHPDoc comment for upcoming declaration */ + protected ?object $commentpending = null; + + /** @var array{ + * 'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int, + * 'parenthesis_opener'?: int, 'parenthesis_closer'?: int, 'attribute_closer'?: int + * } the current token */ + protected array $token = ['code' => null, 'content' => '']; + + /** @var array{ + * 'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int, + * 'parenthesis_opener'?: int, 'parenthesis_closer'?: int, 'attribute_closer'?: int + * } the previous token */ + protected array $tokenprevious = ['code' => null, 'content' => '']; + + /** + * Register for open tag. + * @return array-key[] + */ + public function register(): array { + return [T_OPEN_TAG]; + } + + /** + * 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): int { + + try { + $this->file = $phpcsfile; + $this->tokens = $phpcsfile->getTokens(); + + // Gather atifact info. + $this->artifacts = []; + $this->pass = 1; + $this->typeparser = null; + $this->processPass($stackptr); + + // Check the PHPDoc types. + $this->pass = 2; + $this->typeparser = new PHPDocTypeParser($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(), + $this->fileptr < count($this->tokens) ? $this->fileptr : $this->fileptr - 1, + 'phpdoc_type_parse' + ); + } + + return count($this->tokens); + } + + /** + * A pass over the file. + * @param int $stackptr The position in the stack. + * @return void + * @phpstan-impure + */ + protected function processPass(int $stackptr): void { + $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/*file*/); + } + + /** + * Process the content of a file, class, function, or parameters + * @param \stdClass&object{ + * namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int + * } $scope + * @param 0|1|2 $type 0=file 1=block 2=parameters + * @return void + * @phpstan-impure + */ + protected function processBlock(object $scope, int $type): void { + + // Check we are at the start of a scope, and store scope closer. + if ($type == 0/*file*/) { + if (static::DEBUG_MODE && $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); + } elseif ($type == 1/*block*/) { + if ( + !isset($this->token['scope_opener']) + || $this->token['scope_opener'] != $this->fileptr + || !isset($this->token['scope_closer']) + ) { + throw new \Exception("Malformed block"); + } + $scope->closer = $this->token['scope_closer']; + } else /*parameters*/ { + if ( + !isset($this->token['parenthesis_opener']) + || $this->token['parenthesis_opener'] != $this->fileptr + || !isset($this->token['parenthesis_closer']) + ) { + throw new \Exception("Malformed parameters"); + } + $scope->closer = $this->token['parenthesis_closer']; + } + $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] + ) + ) + && ($this->fileptr < $scope->closer) + ) { + $this->advance(); + } + + if ($this->fileptr >= $scope->closer) { + // End of the block. + break; + } elseif ($this->token['code'] == T_NAMESPACE && $scope->type == 'root') { + // Namespace. + $this->processNamespace($scope); + } elseif ($this->token['code'] == T_USE) { + // Use. + if ($scope->type == 'root' || $scope->type == 'namespace') { + $this->processUse($scope); + } elseif ($scope->type == 'classish') { + $this->processClassTraitUse(); + } else { + $this->advance(T_USE); + throw new \Exception("Unrecognised use of: use"); + } + } elseif ( + 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, ] + ) + ) + ) { + // 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, ] + ) + ) + ) { + // 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]) + ) + ) { + $static = ($this->token['code'] == T_STATIC); + $this->advance(); + } + + // What kind of declaration is this? + if ($static && ($this->token['code'] == T_DOUBLE_COLON || $staticprecededbynew)) { + // It's not a declaration, it's a static late binding. + $this->processPossVarComment($scope, $comment); + continue; + } elseif (in_array($this->token['code'], Tokens::$ooScopeTokens)) { + // Classish thing. + $this->processClassish($scope, $comment); + } elseif (in_array($this->token['code'], [T_FUNCTION, T_CLOSURE, T_FN])) { + // Function. + $this->processFunction($scope, $comment); + } else { + // Variable. + $this->processVariable($scope, $comment); + } + } else { + // We got something unrecognised. + $this->advance(); + throw new \Exception("Unrecognised construct"); + } + } catch (\Exception $e) { + // Just give up on whatever we're doing and try again, unless in debug mode. + if (static::DEBUG_MODE) { + throw $e; + } + } + } + + // Check we are at the end of the scope. + if (static::DEBUG_MODE && $this->fileptr != $scope->closer) { + throw new \Exception("Malformed scope closer"); + } + // We can't consume the last token. Arrow functions close on the token following their body. + } + + /** + * Fetch the current tokens. + * @return void + * @phpstan-impure + */ + protected function fetchToken(): void { + $this->token = ($this->fileptr < count($this->tokens)) ? + $this->tokens[$this->fileptr] + : ['code' => null, 'content' => '']; + } + + /** + * 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): void { + + // Check we have something to fetch, and it's what's expected. + if ($expectedcode && $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) { + $this->processPossVarComment(null, $this->commentpending); + $this->commentpending = null; + } + + $this->tokenprevious = $this->token; + + $this->fileptr++; + $this->fetchToken(); + + // Skip stuff that doesn't affect us, process PHPDoc comments. + while ( + $this->fileptr < count($this->tokens) + && in_array($this->tokens[$this->fileptr]['code'], Tokens::$emptyTokens) + ) { + if (in_array($this->tokens[$this->fileptr]['code'], [T_DOC_COMMENT_OPEN_TAG, T_DOC_COMMENT])) { + // Dispose of unused comment, if any. + if ($this->pass == 2 && $this->commentpending) { + $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'] && $this->pass == 2 && $this->commentpending) { + $this->processPossVarComment(null, $this->commentpending); + $this->commentpending = null; + } + } + + /** + * Advance the token pointer to a specific point. + * @param int $newptr + * @return void + * @phpstan-impure + */ + protected function advanceTo(int $newptr): void { + while ($this->fileptr < $newptr) { + $this->advance(); + } + if ($this->fileptr != $newptr) { + throw new \Exception("Malformed code"); + } + } + + /** + * Process a PHPDoc comment. + * @return void + * @phpstan-impure + */ + protected function processComment(): void { + $commentptr = $this->fileptr; + $this->commentpending = (object)['ptr' => $commentptr, 'tags' => []]; + + // 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, if any. + if ($this->token['code'] == T_DOC_COMMENT_TAG) { + $tagtype = $this->token['content']; + $this->fileptr++; + $this->fetchToken(); + while ( + $this->token['code'] == T_DOC_COMMENT_WHITESPACE + && !in_array(substr($this->token['content'], -1), ["\n", "\r"]) + ) { + $this->fileptr++; + $this->fetchToken(); + } + } else { + $tagtype = ''; + } + + // 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'] && $this->token['code'] != T_DOC_COMMENT_CLOSE_TAG && !$newline) { + if (!$tag->cstartptr) { + $tag->cstartptr = $this->fileptr; + } + $tag->cendptr = $this->fileptr; + $newline = in_array(substr($this->token['content'], -1), ["\n", "\r"]); + $tag->content .= ($newline ? "\n" : $this->token['content']); + $this->fileptr++; + $this->fetchToken(); + } + + // Skip next line starting stuff. + while ( + in_array($this->token['code'], [T_DOC_COMMENT_OPEN_TAG, T_DOC_COMMENT_STAR]) + || $this->token['code'] == T_DOC_COMMENT_WHITESPACE + && !in_array(substr($this->token['content'], -1), ["\n", "\r"]) + ) { + $this->fileptr++; + $this->fetchToken(); + } + } while (!in_array($this->token['code'], [null, T_DOC_COMMENT_CLOSE_TAG, T_DOC_COMMENT_TAG])); + + // Store tag content. + if (!isset($this->commentpending->tags[$tagtype])) { + $this->commentpending->tags[$tagtype] = []; + } + $this->commentpending->tags[$tagtype][] = $tag; + } + + $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(); + } + + /** + * Check for misplaced tags + * @param object{ptr: int, tags: array} $comment + * @param string[] $tagnames What we shouldn't have + * @return void + */ + protected function checkNo(object $comment, array $tagnames): void { + foreach ($tagnames as $tagname) { + if (isset($comment->tags[$tagname])) { + $this->file->addWarning( + "PHPDoc misplaced tag", + $comment->tags[$tagname][0]->ptr, + 'phpdoc_tag_misplaced' + ); + } + } + } + + /** + * Fix a PHPDoc comment tag. + * @param object{ptr: int, content: string, cstartptr: ?int, cendptr: ?int} $tag + * @param string $replacement + * @return void + * @phpstan-impure + */ + protected function fixCommentTag(object $tag, string $replacement): void { + $replacementarray = explode("\n", $replacement); + $replacementcounter = 0; // Place in the replacement array. + $donereplacement = false; // Have we done the replacement at the current position in the array? + $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'] && $this->tokens[$ptr]['code'] != T_DOC_COMMENT_CLOSE_TAG && !$newline) { + $newline = in_array(substr($this->tokens[$ptr]['content'], -1), ["\n", "\r"]); + if (!$newline) { + if ($donereplacement || $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 || $replacementarray[$replacementcounter] === "")) { + // We shouldn't ever end up here. + throw new \Exception("Error during replacement"); + } + $replacementcounter++; + $donereplacement = false; + } + $ptr++; + } + + // Skip next line starting stuff. + while ( + in_array($this->tokens[$ptr]['code'], [T_DOC_COMMENT_OPEN_TAG, T_DOC_COMMENT_STAR]) + || $this->tokens[$ptr]['code'] == T_DOC_COMMENT_WHITESPACE + && !in_array(substr($this->tokens[$ptr]['content'], -1), ["\n", "\r"]) + ) { + $ptr++; + } + } while (!in_array($this->tokens[$ptr]['code'], [null, T_DOC_COMMENT_CLOSE_TAG, T_DOC_COMMENT_TAG])); + + // Check we're done all the expected replacements, otherwise something's gone seriously wrong. + if ( + !($replacementcounter == count($replacementarray) - 1 + && ($donereplacement || $replacementarray[count($replacementarray) - 1] === "")) + ) { + // We shouldn't ever end up here. + throw new \Exception("Error during replacement"); + } + + $this->file->fixer->endChangeset(); + } + + /** + * Process a namespace declaration. + * @param \stdClass&object{ + * namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int + * } $scope + * @return void + * @phpstan-impure + */ + protected function processNamespace(object $scope): void { + + $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] + ) + ) { + $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; + } + + // What kind of namespace is it? + if (!in_array($this->token['code'], [T_OPEN_CURLY_BRACKET, T_SEMICOLON])) { + throw new \Exception("Namespace malformed"); + } + if ($this->token['code'] == T_OPEN_CURLY_BRACKET) { + $scope = clone($scope); + $scope->type = 'namespace'; + $scope->namespace = $namespace; + $this->processBlock($scope, 1/*block*/); + } else { + $scope->namespace = $namespace; + $this->advance(T_SEMICOLON); + } + } + + /** + * Process a use declaration. + * @param \stdClass&object{ + * namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int + * } $scope + * @return void + * @phpstan-impure + */ + protected function processUse(object $scope): void { + + $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); + } elseif ($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] + ) + ) { + $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("Malformed use statement"); + } + $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); + } elseif ($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] + ) + ) { + $namespace .= $this->token['content']; + $this->advance(); + } + + // Figure out the alias. + $alias = substr($namespace, strrpos($namespace, "\\") + 1); + if ($alias == '') { + throw new \Exception("Malformed use statement"); + } + $asalias = $this->processUseAsAlias(); + $alias = $asalias ?? $alias; + + // Store it. + if ($type == 'class') { + $scope->uses[$alias] = $namespace; + } + + $maybemore = ($this->token['code'] == T_COMMA); + if ($maybemore) { + $this->advance(T_COMMA); + } + } while ($maybemore && $this->token['code'] != T_CLOSE_USE_GROUP); + $this->advance(T_CLOSE_USE_GROUP); + } else { + // It's a single import. + // Figure out the alias. + $alias = (strrpos($namespace, "\\") !== false) ? + substr($namespace, strrpos($namespace, "\\") + 1) + : $namespace; + if ($alias == '') { + throw new \Exception("Malformed use statement"); + } + $asalias = $this->processUseAsAlias(); + $alias = $asalias ?? $alias; + + // Store it. + if ($type == 'class') { + $scope->uses[$alias] = $namespace; + } + } + $more = ($this->token['code'] == T_COMMA); + if ($more) { + $this->advance(T_COMMA); + } + } while ($more); + + $this->advance(T_SEMICOLON); + } + + /** + * Process a use as alias. + * @return ?string + * @phpstan-impure + */ + protected function processUseAsAlias(): ?string { + $alias = null; + if ($this->token['code'] == T_AS) { + $this->advance(T_AS); + $alias = $this->token['content']; + $this->advance(T_STRING); + } + return $alias; + } + + /** + * Process a classish thing. + * @param \stdClass&object{ + * namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int + * } $scope + * @param ?( + * \stdClass&object{ + * ptr: int, + * tags: array + * } + * ) $comment + * @return void + * @phpstan-impure + */ + protected function processClassish(object $scope, ?object $comment): void { + + $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); + $name = $name ? $scope->namespace . "\\" . $name : null; + $parent = $this->file->findExtendedClassName($ptr); + if ($parent === false) { + $parent = null; + } elseif ($parent && $parent[0] != "\\") { + if (isset($scope->uses[$parent])) { + $parent = $scope->uses[$parent]; + } else { + $parent = $scope->namespace . "\\" . $parent; + } + } + $interfaces = $this->file->findImplementedInterfaceNames($ptr); + if (!is_array($interfaces)) { + $interfaces = []; + } + foreach ($interfaces as $index => $interface) { + if ($interface && $interface[0] != "\\") { + if (isset($scope->uses[$interface])) { + $interfaces[$index] = $scope->uses[$interface]; + } else { + $interfaces[$index] = $scope->namespace . "\\" . $interface; + } + } + } + $scope->classname = $name; + $scope->parentname = $parent; + + if ($this->pass == 1 && $name) { + // Store details. + $this->artifacts[$name] = (object)['extends' => $parent, 'implements' => $interfaces]; + } elseif ($this->pass == 2) { + // Checks. + + // Check no misplaced tags. + if ($comment) { + $this->checkNo($comment, ['@param', '@return', '@var']); + } + + // Check and store templates. + if ($comment && isset($comment->tags['@template'])) { + $this->processTemplates($scope, $comment); + } + + // Check properties. + if ($comment) { + // Check each property type. + foreach (['@property', '@property-read', '@property-write'] as $tagname) { + if (!isset($comment->tags[$tagname])) { + $comment->tags[$tagname] = []; + } + + // Check each individual property. + foreach ($comment->tags[$tagname] as $docprop) { + $docpropparsed = $this->typeparser->parseTypeAndName( + $scope, + $docprop->content, + 1/*type and name*/, + false/*phpdoc*/ + ); + if (!$docpropparsed->type) { + $this->file->addError( + "PHPDoc class property type missing or malformed", + $docprop->ptr, + 'phpdoc_class_prop_type' + ); + } elseif (!$docpropparsed->name) { + $this->file->addError( + "PHPDoc class property name missing or malformed", + $docprop->ptr, + 'phpdoc_class_prop_name' + ); + } else { + if (static::CHECK_NOT_COMPLEX && $docpropparsed->complex) { + $this->file->addWarning( + "PHPDoc class property type doesn't conform to PHP-FIG PHPDoc", + $docprop->ptr, + 'phpdoc_class_prop_type_complex' + ); + } + + if ($docpropparsed->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc class property type doesn't conform to recommended style", + $docprop->ptr, + 'phpdoc_class_prop_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $docprop, + $docpropparsed->fixed + ); + } + } + } + } + } + } + } + + $parametersptr = isset($token['parenthesis_opener']) ? $token['parenthesis_opener'] : null; + $blockptr = isset($token['scope_opener']) ? $token['scope_opener'] : 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) { + $this->advanceTo($parametersptr); + $this->processBlock($scope, 2/*parameters*/); + } + + // Process the content. + if ($blockptr) { + $this->advanceTo($blockptr); + $this->processBlock($scope, 1/*block*/); + }; + } + + /** + * 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(): void { + $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] + ) + ) { + $this->advance(); + } + + if ($this->token['code'] == T_OPEN_CURLY_BRACKET) { + if (!isset($this->token['bracket_opener']) || !isset($this->token['bracket_closer'])) { + throw new \Exception("Malformed class trait use."); + } + $this->advanceTo($this->token['bracket_closer']); + $this->advance(T_CLOSE_CURLY_BRACKET); + } + + $more = ($this->token['code'] == T_COMMA); + if ($more) { + $this->advance(T_COMMA); + } + } while ($more); + } + + /** + * Process a function. + * @param \stdClass&object{ + * namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int + * } $scope + * @param ?( + * \stdClass&object{ + * ptr: int, + * tags: array + * } + * ) $comment + * @return void + * @phpstan-impure + */ + protected function processFunction(object $scope, ?object $comment): void { + + $ptr = $this->fileptr; + $token = $this->token; + $this->advance(); + + // New scope. + $scope = clone($scope); + $scope->type = 'function'; + $scope->closer = null; + + // Get details. + $name = ($token['code'] == T_FN) ? null : $this->file->getDeclarationName($ptr); + $parametersptr = isset($token['parenthesis_opener']) ? $token['parenthesis_opener'] : null; + $blockptr = isset($token['scope_opener']) ? $token['scope_opener'] : null; + if ( + !$parametersptr + || !isset($this->tokens[$parametersptr]['parenthesis_opener']) + || !isset($this->tokens[$parametersptr]['parenthesis_closer']) + ) { + 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 ( + static::CHECK_HAS_DOCS && $name && !$comment + && (count($parameters) > 0 || strtolower(trim($properties['return_type'])) != 'void') + ) { + $this->file->addWarning( + "PHPDoc function is not documented", + $ptr, + 'phpdoc_fun_doc_missing' + ); + } + + // Check for misplaced tags. + if ($comment) { + $this->checkNo($comment, ['@property', '@property-read', '@property-write', '@var']); + } + + // Check and store templates. + if ($comment && isset($comment->tags['@template'])) { + $this->processTemplates($scope, $comment); + } + + // Check parameter types. + if ($comment) { + // Gather parameter data. + $paramparsedarray = []; + foreach ($parameters as $parameter) { + $paramtext = trim($parameter['content']); + while ( + strpos($paramtext, ' ') + && in_array( + strtolower(substr($paramtext, 0, strpos($paramtext, ' '))), + ['public', 'private', 'protected', 'readonly'] + ) + ) { + $paramtext = trim(substr($paramtext, strpos($paramtext, ' ') + 1)); + } + $paramparsed = $this->typeparser->parseTypeAndName( + $scope, + $paramtext, + 3/*type, modifiers & ..., name, and default value (for implicit null)*/, + true/*native php*/ + ); + if ($paramparsed->name && !isset($paramparsedarray[$paramparsed->name])) { + $paramparsedarray[$paramparsed->name] = $paramparsed; + } + } + + if (!isset($comment->tags['@param'])) { + $comment->tags['@param'] = []; + } + + // Check each individual doc parameter. + $docparamsmatched = []; + foreach ($comment->tags['@param'] as $docparam) { + $docparamparsed = $this->typeparser->parseTypeAndName( + $scope, + $docparam->content, + 2/*type, modifiers & ..., and name*/, + false/*phpdoc*/ + ); + if (!$docparamparsed->type) { + $this->file->addError( + "PHPDoc function parameter type missing or malformed", + $docparam->ptr, + 'phpdoc_fun_param_type' + ); + } elseif (!$docparamparsed->name) { + $this->file->addError( + "PHPDoc function parameter name missing or malformed", + $docparam->ptr, + 'phpdoc_fun_param_name' + ); + } elseif (!isset($paramparsedarray[$docparamparsed->name])) { + // Function parameter doesn't exist. + $this->file->addError( + "PHPDoc function parameter doesn't exist", + $docparam->ptr, + 'phpdoc_fun_param_name_wrong' + ); + } else { + // Compare docs against actual parameter. + + $paramparsed = $paramparsedarray[$docparamparsed->name]; + + if (isset($docparamsmatched[$docparamparsed->name])) { + $this->file->addError( + "PHPDoc function parameter repeated", + $docparam->ptr, + 'phpdoc_fun_param_type_repeat' + ); + } + $docparamsmatched[$docparamparsed->name] = true; + + if (!$this->typeparser->comparetypes($paramparsed->type, $docparamparsed->type)) { + $this->file->addError( + "PHPDoc function parameter type mismatch", + $docparam->ptr, + 'phpdoc_fun_param_type_mismatch' + ); + } + + if (static::CHECK_NOT_COMPLEX && $docparamparsed->complex) { + $this->file->addWarning( + "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + $docparam->ptr, + 'phpdoc_fun_param_type_complex' + ); + } + + if ($docparamparsed->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc function parameter type doesn't conform to recommended style", + $docparam->ptr, + 'phpdoc_fun_param_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $docparam, + $docparamparsed->fixed + ); + } + } + + if ($paramparsed->passsplat != $docparamparsed->passsplat) { + $this->file->addWarning( + "PHPDoc function parameter splat mismatch", + $docparam->ptr, + 'phpdoc_fun_param_pass_splat_mismatch' + ); + } + } + } + + // Check all parameters are documented (if all documented parameters were recognised). + if (static::CHECK_HAS_DOCS && count($docparamsmatched) == count($comment->tags['@param'])) { + foreach ($paramparsedarray as $paramname => $paramparsed) { + if (!isset($docparamsmatched[$paramname])) { + $this->file->addWarning( + "PHPDoc function parameter %s not documented", + $comment->ptr, + 'phpdoc_fun_param_not_documented', + [$paramname] + ); + } + } + } + + // Check parameters are in the correct order. + reset($paramparsedarray); + reset($docparamsmatched); + while (key($paramparsedarray) || key($docparamsmatched)) { + if (key($docparamsmatched) == key($paramparsedarray)) { + next($paramparsedarray); + next($docparamsmatched); + } elseif (key($paramparsedarray) && !isset($docparamsmatched[key($paramparsedarray)])) { + next($paramparsedarray); + } else { + $this->file->addWarning( + "PHPDoc function parameter order wrong", + $comment->ptr, + 'phpdoc_fun_param_order' + ); + break; + } + } + } + + // Check return type. + if ($comment) { + $retparsed = $properties['return_type'] ? + $this->typeparser->parseTypeAndName( + $scope, + $properties['return_type'], + 0/*type only*/, + true/*native php*/ + ) + : (object)['type' => 'mixed']; + if (!isset($comment->tags['@return'])) { + $comment->tags['@return'] = []; + } + if ( + static::CHECK_HAS_DOCS && count($comment->tags['@return']) < 1 + && $name != '__construct' && $retparsed->type != 'void' + ) { + // The old checker didn't check this. + $this->file->addWarning( + "PHPDoc missing function @return tag", + $comment->ptr, + 'phpdoc_fun_ret_missing' + ); + } elseif (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, + 'phpdoc_fun_ret_multiple' + ); + } + + // Check each individual return tag, in case there's more than one. + foreach ($comment->tags['@return'] as $docret) { + $docretparsed = $this->typeparser->parseTypeAndName( + $scope, + $docret->content, + 0/*type only*/, + false/*phpdoc*/ + ); + + if (!$docretparsed->type) { + $this->file->addError( + "PHPDoc function return type missing or malformed", + $docret->ptr, + 'phpdoc_fun_ret_type' + ); + } else { + if (!$this->typeparser->comparetypes($retparsed->type, $docretparsed->type)) { + $this->file->addError( + "PHPDoc function return type mismatch", + $docret->ptr, + 'phpdoc_fun_ret_type_mismatch' + ); + } + + if (static::CHECK_NOT_COMPLEX && $docretparsed->complex) { + $this->file->addWarning( + "PHPDoc function return type doesn't conform to PHP-FIG PHPDoc", + $docret->ptr, + 'phpdoc_fun_ret_type_complex' + ); + } + + if ($docretparsed->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc function return type doesn't conform to recommended style", + $docret->ptr, + 'phpdoc_fun_ret_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $docret, + $docretparsed->fixed + ); + } + } + } + } + } + } + + // Parameters could contain anonymous classes or functions. + if ($parametersptr) { + $this->advanceTo($parametersptr); + $this->processBlock($scope, 2); + } + + // Content. + if ($blockptr) { + $this->advanceTo($blockptr); + $this->processBlock($scope, 1); + }; + } + + /** + * Process templates. + * @param \stdClass&object{ + * namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int + * } $scope + * @param ?( + * \stdClass&object{ + * ptr: int, + * tags: array + * } + * ) $comment + * @return void + * @phpstan-impure + */ + protected function processTemplates(object $scope, ?object $comment): void { + foreach ($comment->tags['@template'] as $doctemplate) { + $doctemplateparsed = $this->typeparser->parseTemplate($scope, $doctemplate->content); + if (!$doctemplateparsed->name) { + $this->file->addError( + "PHPDoc template name missing or malformed", + $doctemplate->ptr, + 'phpdoc_template_name' + ); + } elseif (!$doctemplateparsed->type) { + $this->file->addError( + "PHPDoc template type missing or malformed", + $doctemplate->ptr, + 'phpdoc_template_type' + ); + $scope->templates[$doctemplateparsed->name] = 'never'; + } else { + $scope->templates[$doctemplateparsed->name] = $doctemplateparsed->type; + + if (static::CHECK_NOT_COMPLEX && $doctemplateparsed->complex) { + $this->file->addWarning( + "PHPDoc template type doesn't conform to PHP-FIG PHPDoc", + $doctemplate->ptr, + 'phpdoc_template_type_complex' + ); + } + + if ($doctemplateparsed->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc tempate type doesn't conform to recommended style", + $doctemplate->ptr, + 'phpdoc_template_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $doctemplate, + $doctemplateparsed->fixed + ); + } + } + } + } + } + + /** + * Process a variable. + * @param \stdClass&object{ + * namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int + * } $scope + * @param ?( + * \stdClass&object{ + * ptr: int, + * tags: array + * } + * ) $comment + * @return void + * @phpstan-impure + */ + protected function processVariable(object $scope, ?object $comment): void { + + // Parse var/const token. + $const = ($this->token['code'] == T_CONST); + if ($const) { + $this->advance(T_CONST); + } elseif ($this->token['code'] == T_VAR) { + $this->advance(T_VAR); + } + + // Parse type. + if (!$const) { + // TODO: Add T_TYPE_OPEN_PARENTHESIS and T_TYPE_CLOSE_PARENTHESIS if/when this change happens. + 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, ] + ) + ) { + $this->advance(); + } + } + + // Check name. + if ($this->token['code'] != ($const ? T_STRING : T_VARIABLE)) { + throw new \Exception("Expected declaration."); + } + + // Checking. + if ($this->pass == 2) { + // Get properties, unless it's a function static variable or constant. + $properties = ($scope->type == 'classish' && !$const) ? + $this->file->getMemberProperties($this->fileptr) + : null; + $vartype = ($properties && $properties['type']) ? $properties['type'] : 'mixed'; + + if (static::CHECK_HAS_DOCS && !$comment && $scope->type == 'classish') { + // Require comments for class variables and constants. + $this->file->addWarning( + "PHPDoc variable or constant is not documented", + $this->fileptr, + 'phpdoc_var_doc_missing' + ); + } elseif ($comment) { + // Check for misplaced tags. + $this->checkNo( + $comment, + ['@template', '@property', '@property-read', '@property-write', '@param', '@return'] + ); + + if (!isset($comment->tags['@var'])) { + $comment->tags['@var'] = []; + } + + // Missing var tag. + if (static::CHECK_HAS_DOCS && count($comment->tags['@var']) < 1) { + $this->file->addWarning( + "PHPDoc variable missing @var tag", + $comment->ptr, + 'phpdoc_var_missing' + ); + } + + // Var type check and match. + + $varparsed = $this->typeparser->parseTypeAndName( + $scope, + $vartype, + 0/*type only*/, + true/*native php*/ + ); + + foreach ($comment->tags['@var'] as $docvar) { + $docvarparsed = $this->typeparser->parseTypeAndName( + $scope, + $docvar->content, + 0/*type only*/, + false/*phpdoc*/ + ); + + if (!$docvarparsed->type) { + $this->file->addError( + "PHPDoc var type missing or malformed", + $docvar->ptr, + 'phpdoc_var_type' + ); + } else { + if (!$this->typeparser->comparetypes($varparsed->type, $docvarparsed->type)) { + $this->file->addError( + "PHPDoc var type mismatch", + $docvar->ptr, + 'phpdoc_var_type_mismatch' + ); + } + + if (static::CHECK_NOT_COMPLEX && $docvarparsed->complex) { + $this->file->addWarning( + "PHPDoc var type doesn't conform to PHP-FIG PHPDoc", + $docvar->ptr, + 'phpdoc_var_type_complex' + ); + } + + if ($docvarparsed->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc var type doesn't conform to recommended style", + $docvar->ptr, + 'phpdoc_var_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $docvar, + $docvarparsed->fixed + ); + } + } + } + } + } + } + + $this->advance(); + + if (!in_array($this->token['code'], [T_EQUAL, T_COMMA, T_SEMICOLON, T_CLOSE_PARENTHESIS])) { + throw new \Exception("Expected one of: = , ; )"); + } + } + + /** + * 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: string[], templates: string[], + * 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 + * @return void + * @phpstan-impure + */ + protected function processPossVarComment(?object $scope, ?object $comment): void { + if ($this->pass == 2 && $comment) { + $this->checkNo( + $comment, + ['@template', '@property', '@property-read', '@property-write', '@param', '@return'] + ); + + // Check @var tags if any. + if (isset($comment->tags['@var'])) { + foreach ($comment->tags['@var'] as $docvar) { + $docvarparsed = $this->typeparser->parseTypeAndName( + $scope, + $docvar->content, + 0/*type only*/, + false/*phpdoc*/ + ); + + if (!$docvarparsed->type) { + $this->file->addError( + "PHPDoc var type missing or malformed", + $docvar->ptr, + 'phpdoc_var_type' + ); + } else { + if (static::CHECK_NOT_COMPLEX && $docvarparsed->complex) { + $this->file->addWarning( + "PHPDoc var type doesn't conform to PHP-FIG PHPDoc", + $docvar->ptr, + 'phpdoc_var_type_complex' + ); + } + + if ($docvarparsed->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc var type doesn't conform to recommended style", + $docvar->ptr, + 'phpdoc_var_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $docvar, + $docvarparsed->fixed + ); + } + } + } + } + } + } + } +} diff --git a/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php new file mode 100644 index 0000000..0a59a9c --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php @@ -0,0 +1,145 @@ +. + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting; + +use MoodleHQ\MoodleCS\moodle\Tests\MoodleCSBaseTestCase; + +/** + * Test the PHPDocTypes sniff. + * + * @author James Calder + * @copyright based on work by 2024 onwards Andrew Lyons + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \MoodleHQ\MoodleCS\moodle\Sniffs\Commenting\PHPDocTypesSniff + */ +class PHPDocTypesSniffTest extends MoodleCSBaseTestCase +{ + /** + * @dataProvider provider + * @param string $fixture + * @param array $errors + * @param array $warnings + */ + public function testPHPDocTypesCorrectness( + string $fixture, + array $errors, + array $warnings + ): void { + $this->setStandard('moodle'); + $this->setSniff('moodle.Commenting.PHPDocTypes'); + $this->setFixture(sprintf("%s/fixtures/%s.php", __DIR__, $fixture)); + $this->setWarnings($warnings); + $this->setErrors($errors); + /*$this->setApiMappings([ + 'test' => [ + 'component' => 'core', + 'allowspread' => true, + 'allowlevel2' => false, + ], + ]);*/ + + $this->verifyCsResults(); + } + + /** + * @return array + */ + public static function provider(): array { + return [ + 'PHPDocTypes complex warn' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_complex_warn', + 'errors' => [], + 'warnings' => [ + 39 => "PHPDoc template type doesn't conform to PHP-FIG PHPDoc", + 40 => "PHPDoc class property type doesn't conform to PHP-FIG PHPDoc", + 45 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 46 => "PHPDoc function return type doesn't conform to PHP-FIG PHPDoc", + 52 => "PHPDoc var type doesn't conform to PHP-FIG PHPDoc", + 57 => "PHPDoc var type doesn't conform to PHP-FIG PHPDoc", + ], + ], + /*'PHPDocTypes docs missing warn' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_docs_missing_warn', + 'errors' => [], + 'warnings' => [ + 40 => "PHPDoc function is not documented", + 43 => 2, + 52 => "PHPDoc variable or constant is not documented", + 54 => "PHPDoc variable missing @var tag", + ], + ],*/ + 'PHPDocTypes general right' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_general_right', + 'errors' => [], + 'warnings' => [], + ], + 'PHPDocTypes general wrong' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_general_wrong', + 'errors' => [ + 41 => "PHPDoc class property type missing or malformed", + 42 => "PHPDoc class property name missing or malformed", + 48 => "PHPDoc function parameter type missing or malformed", + 49 => "PHPDoc function parameter name missing or malformed", + 50 => "PHPDoc function parameter doesn't exist", + 52 => "PHPDoc function parameter repeated", + 53 => "PHPDoc function parameter type mismatch", + 64 => "PHPDoc multiple function @return tags--Put in one tag, seperated by vertical bars |", + 72 => "PHPDoc function return type missing or malformed", + 79 => "PHPDoc function return type mismatch", + 87 => "PHPDoc template name missing or malformed", + 88 => "PHPDoc template type missing or malformed", + 94 => "PHPDoc var type missing or malformed", + 97 => "PHPDoc var type mismatch", + 102 => "PHPDoc var type missing or malformed", + ], + 'warnings' => [ + 31 => "PHPDoc misplaced tag", + 46 => "PHPDoc function parameter order wrong", + 54 => "PHPDoc function parameter splat mismatch", + ], + ], + 'PHPDocTypes namespace right' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_namespace_right', + 'errors' => [], + 'warnings' => [], + ], + 'PHPDocTypes parse wrong' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_parse_wrong', + 'errors' => [ + 91 => "PHPDoc function parameter type mismatch", + ], + 'warnings' => [], + ], + 'PHPDocTypes style warn' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_style_warn', + 'errors' => [], + 'warnings' => [ + 36 => "PHPDoc class property type doesn't conform to recommended style", + 41 => "PHPDoc function parameter type doesn't conform to recommended style", + 42 => "PHPDoc function return type doesn't conform to recommended style", + 43 => "PHPDoc tempate type doesn't conform to recommended style", + 49 => "PHPDoc var type doesn't conform to recommended style", + 52 => "PHPDoc var type doesn't conform to recommended style", + 56 => "PHPDoc var type doesn't conform to recommended style", + 63 => "PHPDoc var type doesn't conform to recommended style", + ], + ], + ]; + } +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_complex_warn.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_complex_warn.php new file mode 100644 index 0000000..02a099a --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_complex_warn.php @@ -0,0 +1,58 @@ +. + +/** + * A collection of valid types for testing + * + * This file should have no errors when checked with either PHPStan or Psalm. + * Having just valid code in here means it can be easily checked with other checkers, + * to verify we are actually checking against correct examples. + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures; + +/** + * A collection of valid types for testing + * + * @package local_codechecker + * @copyright 2023 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + * @template T of ?int + * @property ?int $p + */ +class php_valid { + + /** + * @param ?int $p + * @return ?int + */ + function f(?int $p): ?int { + return $p; + } + + /** @var ?int */ + public ?int $v; + +} + +/** @var ?int */ +$v2 = 0; diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_warn.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_warn.php new file mode 100644 index 0000000..21aa95a --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_warn.php @@ -0,0 +1,57 @@ +. + +/** + * A collection code with missing annotations for testing + * + * These should pass PHPStan and Psalm. + * But warnings should be given by the PHPDocTypesSniff when CHECK_HAS_DOCS is enabled. + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +/** + * A collection of code with missing annotations for testing + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ +class types_invalid { + + // PHPDoc function is not documented + public function fun_not_doc(int $p): void { + } + + /** + * PHPDoc function parameter $p not documented + * PHPDoc missing function @return tag + */ + public function fun_missing_param_ret(int $p): int { + return $p; + } + + // PHPDoc variable or constant is not documented + public int $v1 = 0; + + /** PHPDoc missing @var tag */ + public int $v2 = 0; + +} \ No newline at end of file diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php new file mode 100644 index 0000000..818c7c0 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php @@ -0,0 +1,136 @@ +. + +/** + * A collection of valid types for testing + * + * This file should have no errors when checked with either PHPStan or Psalm (but a warning about an unused var). + * Having just valid code in here means it can be easily checked with other checkers, + * to verify we are actually checking against correct examples. + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures; + +use stdClass as myStdClass, Exception; +use MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\ {PHPDocTypesSniffTest}; + +?> +. + +/** + * A collection of invalid types for testing + * + * Most type annotations give an error either when checked with PHPStan or Psalm. + * Having just invalid types in here means the number of errors should match the number of type annotations. + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +/** + * PHPDoc misplaced tag + * @property int $p + */ + +/** + * A collection of invalid types for testing + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + * @property int< PHPDoc class property type missing or malformed + * @property int PHPDoc class property name missing or malformed + */ +class types_invalid { + + /** + * Function parameter issues + * @param int< PHPDoc function parameter type missing or malformed + * @param int PHPDoc function parameter name missing or malformed + * @param int $p1 PHPDoc function parameter doesn't exist + * @param int $p2 + * @param int $p2 PHPDoc function parameter repeated + * @param string $p3 PHPDoc function parameter type mismatch + * @param int ...$p5 PHPDoc function parameter splat mismatch + * @param int $p4 PHPDoc function parameter order wrong + * @return void + */ + public function function_parameter_issues(int $p2, int $p3, int $p4, int $p5): void { + } + + /** + * PHPDoc multiple function @return tags--Put in one tag, seperated by vertical bars | + * @return int + * @return null + */ + function multiple_returns(): ?int { + return 0; + } + + /** + * PHPDoc function return type missing or malformed + * @return + */ + function return_malformed(): void { + } + + /** + * PHPDoc function return type mismatch + * @return string + */ + function return_mismatch(): int { + return 0; + } + + /** + * Template issues + * @template @ PHPDoc template name missing or malformed + * @template T of @ PHPDoc template type missing or malformed + * @return void + */ + function template_issues(): void { + } + + /** @var @ PHPDoc var type missing or malformed */ + public int $var_type_malformed; + + /** @var string PHPDoc var type mismatch */ + public int $var_type_mismatch; + +} + +/** @var @ PHPDoc var type missing or malformed (not class var) */ +$var_type_malformed_2 = 0; diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_namespace_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_namespace_right.php new file mode 100644 index 0000000..c5c8939 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_namespace_right.php @@ -0,0 +1,50 @@ +. + +/** + * A collection of valid types for testing + * + * This file should have no errors when checked with either PHPStan or Psalm. + * Having just valid code in here means it can be easily checked with other checkers, + * to verify we are actually checking against correct examples. + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures { + + /** + * A collection of valid types for testing + * + * @package local_codechecker + * @copyright 2023 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + class php_valid { + /** + * Namespaces recognised + * @param \MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures\php_valid $x + * @return void + */ + function namespaces(php_valid $x): void { + } + } + +} \ No newline at end of file diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php new file mode 100644 index 0000000..3c65535 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php @@ -0,0 +1,94 @@ +. + +/** + * A collection of parse errors for testing + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +namespace trailing_backslash\; + +namespace @ // Malformed. + +use no_trailing_backslash {something}; + +use trailing_backslash\; + +use x\ { ; // No bracket closer. + +use x\ {}; // No content. + +use x as @; // Malformed as clause. + +use x @ // No terminator. + +/** @var int */ +public int $wrong_place_1; + +/** */ +function wrong_places(): void { + namespace ns; + use x; + /** */ + class c {} + /** @var int */ + public int $wrong_place_2; +} + +/** + * A collection of parse errors for testing + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ +class types_invalid // No block + +/** */ +class c { // No block close + +/** */ +class c { + use T { @ +} + +/** */ +function f: void {} // No parameters + +/** */ +function f( : void {} // No parameters close + +/** */ +function f(): void // No block + +/** */ +function f(): void { // No block close + +/** */ +public @ // Malformed declaration. + +/** @var int */ +public int $v @ // Unterminated variable. + +/** @param string $p */ +function f(int $p): void {}; // Do we still reach here, and detect an error? + +/** Unclosed Doc comment diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php new file mode 100644 index 0000000..a8ea33b --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php @@ -0,0 +1,64 @@ +. + +/** + * A collection of types not in recommended style for testing + * + * These needn't give errors in PHPStan or Psalm. + * But the PHPDocTypesSniff should give warnings. + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +/** + * A collection of types not in recommended style for testing + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + * @property Integer $p PHPDoc class property type doesn't conform to recommended style + */ +class types_invalid { + + /** + * @param Boolean|T $p PHPDoc function parameter type doesn't conform to recommended style + * @return Integer PHPDoc function return type doesn't conform to recommended style + * @template T of Integer PHPDoc tempate type doesn't conform to recommended style + */ + public function fun_wrong($p): int { + return 0; + } + + /** @var Integer PHPDoc var type doesn't conform to recommended style */ + public int $v1; + + /** @var Integer + * | Boolean Multiline type, no line break at end */ + public int|bool $v2; + + /** @var Integer + * | Boolean Multiline type, line break at end + */ + public int|bool $v3; + +} + +/** @var Integer PHPDoc var type doesn't conform to recommended style (not class var) */ +$v4 = 0; diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php.fixed b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php.fixed new file mode 100644 index 0000000..146c1ad --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php.fixed @@ -0,0 +1,64 @@ +. + +/** + * A collection of types not in recommended style for testing + * + * These needn't give errors in PHPStan or Psalm. + * But the PHPDocTypesSniff should give warnings. + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +/** + * A collection of types not in recommended style for testing + * + * @package local_codechecker + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + * @property int $p PHPDoc class property type doesn't conform to recommended style + */ +class types_invalid { + + /** + * @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 fun_wrong($p): int { + return 0; + } + + /** @var int PHPDoc var type doesn't conform to recommended style */ + public int $v1; + + /** @var int + * | bool Multiline type, no line break at end */ + public int|bool $v2; + + /** @var int + * | bool Multiline type, line break at end + */ + public int|bool $v3; + +} + +/** @var int PHPDoc var type doesn't conform to recommended style (not class var) */ +$v4 = 0; diff --git a/moodle/Tests/Util/PHPDocTypeParserTest.php b/moodle/Tests/Util/PHPDocTypeParserTest.php new file mode 100644 index 0000000..6f8498a --- /dev/null +++ b/moodle/Tests/Util/PHPDocTypeParserTest.php @@ -0,0 +1,183 @@ +. + +namespace MoodleHQ\MoodleCS\moodle\Tests\Util; + +use MoodleHQ\MoodleCS\moodle\Tests\MoodleCSBaseTestCase; + +/** + * Test the PHPDocTypeParser. + * + * @author James Calder + * @copyright based on work by 2024 onwards Andrew Lyons + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \MoodleHQ\MoodleCS\moodle\Util\PHPDocTypeParser + */ +class PHPDocTypeParserTest extends MoodleCSBaseTestCase +{ + /** + * @dataProvider provider + * @param string $fixture + * @param array $errors + * @param array $warnings + */ + public function testPHPDocTypesParser( + string $fixture, + array $errors, + array $warnings + ): void { + $this->setStandard('moodle'); + $this->setSniff('moodle.Commenting.PHPDocTypes'); + $this->setFixture(sprintf("%s/fixtures/%s.php", __DIR__, $fixture)); + $this->setWarnings($warnings); + $this->setErrors($errors); + /*$this->setApiMappings([ + 'test' => [ + 'component' => 'core', + 'allowspread' => true, + 'allowlevel2' => false, + ], + ]);*/ + + $this->verifyCsResults(); + } + + /** + * @return array + */ + public static function provider(): array { + return [ + 'PHPDocTypes complex warn' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_complex_warn', + 'errors' => [], + 'warnings' => [ + 54 => "PHPDoc var type doesn't conform to PHP-FIG PHPDoc", + 82 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 102 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 105 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 106 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 107 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 129 => "PHPDoc function parameter type doesn't conform to recommended style", + 138 => "PHPDoc function parameter type doesn't conform to recommended style", + 139 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 140 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 141 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 142 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 151 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 152 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 153 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 161 => "PHPDoc function parameter type doesn't conform to recommended style", + 162 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 171 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 172 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 173 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 181 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 189 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 190 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 191 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 192 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 193 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 202 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 211 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 212 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 214 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 215 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 216 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 225 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 233 => "PHPDoc function return type doesn't conform to recommended style", + 242 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 243 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 263 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 264 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 265 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 266 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 267 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 276 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 277 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 278 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 286 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 287 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 295 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 296 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 304 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 305 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 314 => "PHPDoc function return type doesn't conform to PHP-FIG PHPDoc", + 322 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 323 => "PHPDoc function return type doesn't conform to PHP-FIG PHPDoc", + 331 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 332 => "PHPDoc function return type doesn't conform to PHP-FIG PHPDoc", + 340 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 341 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 342 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 343 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 344 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 345 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 346 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 347 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 356 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 358 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 375 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 378 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 379 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 380 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 384 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 385 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 388 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 443 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 460 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + 461 => "PHPDoc function parameter type doesn't conform to PHP-FIG PHPDoc", + ], + ], + 'PHPDocTypes parse wrong' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_parse_wrong', + 'errors' => [ + 41 => 'PHPDoc function parameter name missing or malformed', + 49 => 'PHPDoc function parameter name missing or malformed', + 56 => 'PHPDoc var type missing or malformed', + 59 => 'PHPDoc var type missing or malformed', + 63 => 'PHPDoc var type missing or malformed', + 67 => 'PHPDoc var type missing or malformed', + 71 => 'PHPDoc var type missing or malformed', + 74 => 'PHPDoc var type missing or malformed', + 77 => 'PHPDoc var type missing or malformed', + 80 => 'PHPDoc var type missing or malformed', + 83 => 'PHPDoc var type missing or malformed', + 86 => 'PHPDoc var type missing or malformed', + 89 => 'PHPDoc var type missing or malformed', + 93 => 'PHPDoc var type missing or malformed', + 96 => 'PHPDoc var type missing or malformed', + 99 => 'PHPDoc var type missing or malformed', + 102 => 'PHPDoc var type missing or malformed', + 105 => 'PHPDoc var type missing or malformed', + 108 => 'PHPDoc var type missing or malformed', + 111 => 'PHPDoc var type missing or malformed', + 114 => 'PHPDoc var type missing or malformed', + 119 => 'PHPDoc function parameter type missing or malformed', + 126 => 'PHPDoc var type missing or malformed', + 129 => 'PHPDoc var type missing or malformed', + ], + 'warnings' => [], + ], + 'PHPDocTypes simple right' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_simple_right', + 'errors' => [], + 'warnings' => [], + ], + ]; + } +} diff --git a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_complex_warn.php b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_complex_warn.php new file mode 100644 index 0000000..b0b2798 --- /dev/null +++ b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_complex_warn.php @@ -0,0 +1,470 @@ +. + +/** + * A collection of valid types for testing + * + * This file should have no errors when checked with either PHPStan or Psalm. + * Having just valid code in here means it can be easily checked with other checkers, + * to verify we are actually checking against correct examples. + * + * @package local_codechecker + * @copyright 2023 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +use stdClass as MyStdClass; + +/** + * A parent class + */ +class types_valid_parent { +} + +/** + * An interface + */ +interface types_valid_interface { +} + +/** + * A collection of valid types for testing + * + * @package local_codechecker + * @copyright 2023 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ +class types_valid extends types_valid_parent implements types_valid_interface { + + /** @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 types_valid $specificclass + * @param callable $callable + * @return void + */ + public function basic_type_equivalence( + bool $bool, + int $int, + float $float, + string $string, + object $object, + self $self, + parent $parent, + types_valid $specificclass, + callable $callable + ): void { + } + + /** + * 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 non_native_types($parameterisedarray, $resource, $static, $parameterisediterable, + $arraykey, $scalar, $mixed) { + throw new \Exception(); + } + + /** + * Parameter modifiers + * @param object &$reference + * @param int ...$splat + * @return void + */ + public function parameter_modifiers( + object &$reference, + int ...$splat): void { + } + + /** + * Boolean types + * @param bool|boolean $bool + * @param true|false $literal + * @return void + */ + public function boolean_types(bool $bool, bool $literal): void { + } + + /** + * 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 integer_types(int $int, int $intrange1, int $intrange2, + int $literal1, int $intmask1): void { + } + + /** + * 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 integer_types_complex(int $literal2, int $intmask2, int $intmask3): void { + } + + /** + * Float types + * @param float|double $float + * @param 1.0|-1.0 $literal + * @return void + */ + public function float_types(float $float, float $literal): void { + } + + /** + * 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 string_types(string $string, string $classstring1, string $other, string $literal): void { + } + + /** + * String types complex + * @param '\'' $stringwithescape + * @return void + */ + public function string_types_complex(string $stringwithescape): void { + } + + /** + * Array types + * @param types_valid[]|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 array_types(array $genarray1, array $genarray2, array $list, + array $shapes1, array $shapes2): void { + } + + /** + * Array types complex + * @param array $genarray3 + * @return void + */ + public function array_types_complex(array $genarray3): void { + } + + /** + * 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 types_valid $class + * @param self|parent|static|$this $relative + * @param Traversable|Traversable $traversable1 + * @param \Closure|\Closure(int, int): string $closure + * @return void + */ + public function object_types(object $object, object $shapes1, object $shapes2, object $class, + object $relative, object $traversable1, object $closure): void { + } + + /** + * Object types complex + * @param Traversable<1|2, types_valid|types_valid_interface>|Traversable $traversable2 + * @return void + */ + public function object_types_complex(object $traversable2): void { + } + + /** + * Never type + * @return never|never-return|never-returns|no-return + */ + public function never_type() { + throw new \Exception(); + } + + /** + * Null type + * @param null $standalonenull + * @param ?int $explicitnullable + * @param ?int $implicitnullable + * @return void + */ + public function null_type( + $standalonenull, + ?int $explicitnullable, + int $implicitnullable=null + ): void { + } + + /** + * User-defined type + * @param types_valid|\types_valid $class + * @return void + */ + public function user_defined_type(types_valid $class): void { + } + + /** + * 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 callable_types(callable $callable1, callable $callable2, callable $callable3, + callable $closure, callable $callablestring): void { + } + + /** + * Iterable types + * @param array $array + * @param iterable|iterable $iterable1 + * @param Traversable|Traversable $traversable1 + * @return void + */ + public function iterable_types(iterable $array, iterable $iterable1, iterable $traversable1): void { + } + + /** + * Iterable types complex + * @param iterable<1|2, types_valid>|iterable $iterable2 + * @param Traversable<1|2, types_valid>|Traversable $traversable2 + * @return void + */ + public function iterable_types_complex(iterable $iterable2, iterable $traversable2): void { + } + + /** + * Key and value of + * @param key-of $keyof1 + * @param value-of $valueof1 + * @return void + */ + public function key_and_value_of(int $keyof1, string $valueof1): void { + } + + /** + * Key and value of complex + * @param key-of> $keyof2 + * @param value-of> $valueof2 + * @return void + */ + public function key_and_value_of_complex(int $keyof2, string $valueof2): void { + } + + /** + * Conditional return types + * @param int $size + * @return ($size is positive-int ? non-empty-array : array) + */ + public function conditional_return(int $size): array { + return ($size > 0) ? array_fill(0, $size, "entry") : []; + } + + /** + * Conditional return types complex 1 + * @param types_valid::INT_*|types_valid::STRING_* $x + * @return ($x is types_valid::INT_* ? types_valid::INT_* : types_valid::STRING_*) + */ + public function conditional_return_complex_1($x) { + return $x; + } + + /** + * Conditional return types complex 2 + * @param 1|2|'Hello'|'World' $x + * @return ($x is 1|2 ? 1|2 : 'Hello'|'World') + */ + public function conditional_return_complex_2($x) { + return $x; + } + + /** + * Constant enumerations + * @param types_valid::BOOL_FALSE|types_valid::BOOL_TRUE|types_valid::BOOL_* $bool + * @param types_valid::INT_ONE $int1 + * @param types_valid::INT_ONE|types_valid::INT_TWO $int2 + * @param self::INT_* $int3 + * @param types_valid::* $mixed + * @param types_valid::FLOAT_1_0|types_valid::FLOAT_2_0 $float + * @param types_valid::STRING_HELLO $string + * @param types_valid::ARRAY_CONST $array + * @return void + */ + public function constant_enumerations(bool $bool, int $int1, int $int2, int $int3, $mixed, + float $float, string $string, array $array): void { + } + + /** + * Basic structure + * @param ?int $nullable + * @param int|string $union + * @param types_valid&object{additionalproperty: string} $intersection + * @param (int) $brackets + * @param int[] $arraysuffix + * @return void + */ + public function basic_structure( + ?int $nullable, + $union, + object $intersection, + int $brackets, + array $arraysuffix + ): void { + } + + /** + * Structure combinations + * @param int|float|string $multipleunion + * @param types_valid&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 (types_valid&object{additionalproperty: string}) $intersectionbracket1 + * @param types_valid&(object{additionalproperty: string}) $intersectionbracket2 + * @param (int)[] $bracketarray1 + * @param (int[]) $bracketarray2 + * @param int|(types_valid&object{additionalproperty: string}) $dnf + * @return void + */ + public function structure_combos( + $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 { + } + + /** + * Inheritance + * @param types_valid $basic + * @param self|static|$this $relative1 + * @param types_valid $relative2 + * @return void + */ + public function inheritance( + types_valid_parent $basic, + parent $relative1, + parent $relative2 + ): void { + } + + /** + * Template + * @template T of int + * @param T $template + * @return void + */ + public function template(int $template): void { + } + + /** + * Use alias + * @param stdClass $use + * @return void + */ + public function uses(MyStdClass $use): void { + } + + /** + * 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 builtin_classes( + Traversable $traversable, Iterator $iterator, + Throwable $throwable, Exception $exception, Error $error, + ArithmeticError $arithmeticerror + ): void { + } + + /** + * 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 spl_classes( + Iterator $iterator, SeekableIterator $seekableiterator, Countable $countable + ): void { + } + +} diff --git a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php new file mode 100644 index 0000000..1389c6f --- /dev/null +++ b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php @@ -0,0 +1,132 @@ +. + +/** + * A collection of invalid types for testing + * + * Every type annotation should give an error either when checked with PHPStan or Psalm. + * Having just invalid types in here means the number of errors should match the number of type annotations. + * + * @package local_codechecker + * @copyright 2023 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +/** + * A collection of invalid types for testing + * + * @package local_codechecker + * @copyright 2023 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ +class types_invalid { + + /** + * Expecting variable name, saw end + * @param int + * @return void + */ + public function expecting_var_saw_end(int $x): void { + } + + /** + * Expecting variable name, saw other (passes Psalm) + * @param int int + * @return void + */ + public function expecting_var_saw_other(int $x): void { + } + + // Expecting type, saw end. + /** @var */ + public $expectingtypesawend; + + /** @var $varname Expecting type, saw other */ + 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; + + /** @var types_invalid&(a|b) Non-DNF type (passes PHPStan) */ + public $nondnftype; + + /** @var int&string Invalid intersection */ + public $invalidintersection; + + /** @var int<0.0, 1> Invalid int min */ + public $invalidintmin; + + /** @var int<0, 1.0> Invalid int max */ + public $invalidintmax; + + /** @var int-mask<1.0, 2.0> Invalid int mask 1 */ + public $invalidintmask1; + + /** @var int-mask-of Invalid int mask 2 */ + public $invalidintmask2; + + // Expecting class for class-string, saw end. + /** @var class-string< */ + public $expectingclassforclassstringsawend; + + /** @var class-string Expecting class for class-string, saw other */ + public $expectingclassforclassstringsawother; + + /** @var list List key */ + public $listkey; + + /** @var array Invalid array key (passes Psalm) */ + public $invalidarraykey; + + /** @var non-empty-array{'a': int} Non-empty-array shape */ + public $nonemptyarrayshape; + + /** @var object{0.0: int} Invalid object key (passes Psalm) */ + public $invalidobjectkey; + + /** @var key-of Can't get key of non-iterable */ + public $cantgetkeyofnoniterable; + + /** @var value-of Can't get value of non-iterable */ + public $cantgetvalueofnoniterable; + + /** + * Class name has trailing slash + * @param types_invalid\ $x + * @return void + */ + public function class_name_has_trailing_slash(object $x): void { + } + + // Expecting closing bracket, saw end. + /** @var (types_invalid */ + public $expectingclosingbracketsawend; + + /** @var (types_invalid int Expecting closing bracket, saw other*/ + public $expectingclosingbracketsawother; + +} diff --git a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_simple_right.php b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_simple_right.php new file mode 100644 index 0000000..5d0b65e --- /dev/null +++ b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_simple_right.php @@ -0,0 +1,269 @@ +. + +/** + * A collection of valid types for testing + * + * This file should have no errors when checked with either PHPStan or Psalm, other than no value for iterable. + * Having just valid code in here means it can be easily checked with other checkers, + * to verify we are actually checking against correct examples. + * + * @package local_codechecker + * @copyright 2023 onwards Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +use stdClass as MyStdClass; + +/** + * A parent class + */ +class types_valid_parent { +} + +/** + * An interface + */ +interface types_valid_interface { +} + +/** + * A collection of valid types for testing + * + * @package local_codechecker + * @copyright 2023 onwards Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ +class types_valid extends types_valid_parent implements types_valid_interface { + + /** + * 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 types_valid $specificclass + * @param callable $callable + * @return void + */ + public function basic_type_equivalence( + array $array, + bool $bool, + int $int, + float $float, + string $string, + object $object, + self $self, + iterable $iterable, + types_valid $specificclass, + callable $callable + ): void { + } + + /** + * Types not supported natively (as of PHP 7.2) + * @param resource $resource + * @param static $static + * @param mixed $mixed + * @return never + */ + public function non_native_types($resource, $static, $mixed) { + throw new \Exception(); + } + + /** + * Parameter modifiers + * @param object &$reference + * @param int ...$splat + * @return void + */ + public function parameter_modifiers( + object &$reference, + int ...$splat): void { + } + + /** + * Boolean types + * @param bool $bool + * @param true|false $literal + * @return void + */ + public function boolean_types(bool $bool, bool $literal): void { + } + + + /** + * Object types + * @param object $object + * @param types_valid $class + * @param self|static|$this $relative + * @param Traversable $traversable + * @param \Closure $closure + * @return void + */ + public function object_types(object $object, object $class, + object $relative, object $traversable, object $closure): void { + } + + /** + * Null type + * @param null $standalonenull + * @param int|null $explicitnullable + * @param int|null $implicitnullable + * @return void + */ + public function null_type( + $standalonenull, + ?int $explicitnullable, + int $implicitnullable=null + ): void { + } + + /** + * User-defined type + * @param types_valid|\types_valid $class + * @return void + */ + public function user_defined_type(types_valid $class): void { + } + + /** + * Callable types + * @param callable $callable + * @param \Closure $closure + * @return void + */ + public function callable_types(callable $callable, callable $closure): void { + } + + /** + * Iterable types + * @param array $array + * @param iterable $iterable + * @param Traversable $traversable + * @return void + */ + public function iterable_types(iterable $array, iterable $iterable, iterable $traversable): void { + } + + /** + * Basic structure + * @param int|string $union + * @param types_valid&object $intersection + * @param int[] $arraysuffix + * @return void + */ + public function basic_structure( + $union, + object $intersection, + array $arraysuffix + ): void { + } + + /** + * Structure combinations + * @param int|float|string $multipleunion + * @param types_valid&object&\Traversable $multipleintersection + * @param int[][] $multiplearray + * @param int|int[] $unionarray + * @param (int)[] $bracketarray + * @param int|(types_valid&object) $dnf + * @return void + */ + public function structure_combos( + $multipleunion, + object $multipleintersection, + array $multiplearray, + $unionarray, + array $bracketarray, + $dnf + ): void { + } + + /** + * DocType DNF vs Native DNF + * @param int|(types_valid_parent&types_valid_interface) $p + */ + function dnf_vs_dnf((types_valid_interface&types_valid_parent)|int $p): void { + } + + /** + * Inheritance + * @param types_valid $basic + * @param self|static|$this $relative1 + * @param types_valid $relative2 + * @return void + */ + public function inheritance( + types_valid_parent $basic, + parent $relative1, + parent $relative2 + ): void { + } + + /** + * Template + * @template T of int + * @param T $template + * @return void + */ + public function template(int $template): void { + } + + /** + * Use alias + * @param stdClass $use + * @return void + */ + public function uses(MyStdClass $use): void { + } + + /** + * 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 builtin_classes( + Traversable $traversable, Iterator $iterator, + Throwable $throwable, Exception $exception, Error $error, + ArithmeticError $arithmeticerror + ): void { + } + + /** + * 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 spl_classes( + Iterator $iterator, SeekableIterator $seekableiterator, Countable $countable + ): void { + } + +} diff --git a/moodle/Util/PHPDocTypeParser.php b/moodle/Util/PHPDocTypeParser.php new file mode 100644 index 0000000..348a862 --- /dev/null +++ b/moodle/Util/PHPDocTypeParser.php @@ -0,0 +1,1157 @@ +. + +/** + * Type parser + * + * Checks that PHPDoc types are well formed, and returns a simplified version if so, or null otherwise. + * Global constants and the Collection|Type[] construct aren't supported. + * + * @copyright 2023-2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause + */ + +declare(strict_types=1); + +namespace MoodleHQ\MoodleCS\moodle\Util; + +/** + * Type parser + */ +class PHPDocTypeParser +{ + /** @var array predefined and SPL classes */ + protected array $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" => [], + ]; + + /** @var array inheritance heirarchy */ + protected array $artifacts; + + /** @var object{namespace: string, uses: string[], templates: string[], classname: ?string, parentname: ?string} */ + protected object $scope; + + /** @var string the text to be parsed */ + protected string $text = ''; + + /** @var array */ + protected array $replacements = []; + + /** @var bool when we encounter an unknown type, should we go wide or narrow */ + protected bool $gowide = false; + + /** @var bool whether the type is complex (includes things not in the PHP-FIG PHPDoc standard) */ + protected bool $complex = false; + + /** @var object{startpos: non-negative-int, endpos: non-negative-int, text: ?non-empty-string}[] next tokens */ + protected array $nexts = []; + + /** @var ?non-empty-string the next token */ + protected ?string $next = null; + + /** + * Constructor + * @param ?array $artifacts + */ + public function __construct(?array $artifacts = null) { + $this->artifacts = $artifacts ?? []; + } + + /** + * Parse a type and possibly variable name + * @param ?object{namespace: string, uses: string[], templates: string[], classname: ?string, parentname: ?string} $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: ?non-empty-string, passsplat: string, name: ?non-empty-string, + * rem: string, fixed: ?string, complex: bool} + * the simplified type, pass by reference & splat, variable name, remaining text, fixed text, and whether complex + */ + public function parseTypeAndName(?object $scope, string $text, int $getwhat, bool $gowide): object { + + // Initialise variables. + if ($scope) { + $this->scope = $scope; + } else { + $this->scope = (object)['namespace' => '', 'uses' => [], 'templates' => [], 'classname' => null, 'parentname' => null]; + } + $this->text = $text; + $this->replacements = []; + $this->gowide = $gowide; + $this->complex = false; + $this->nexts = []; + $this->next = $this->next(); + + // 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)) + || in_array($this->next, [',', ';', ':', '.'])) + ) { + // Code smell check. + throw new \Exception("Warning parsing type, no space after type."); + } + } catch (\Exception $e) { + $this->nexts = $savednexts; + $this->next = $this->next(); + $type = null; + } + + // Try to parse pass by reference and splat. + $passsplat = ''; + if ($getwhat >= 2) { + if ($this->next == '&') { + // Not adding this for code smell check, + // because the old checker disallowed pass by reference & in PHPDocs, + // so adding this would be a nusiance for people who changed their PHPDocs + // to conform to the previous rules, and would make it impossible to conform + // if both checkers were used. + $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("Error parsing type, expected variable, 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)) + || in_array($this->next, [',', ';', ':', '.'])) + ) { + // Code smell check. + throw new \Exception("Warning parsing type, no space after variable name."); + } + // Implicit nullable + // TODO: This is deprecated in PHP 8.4, so this should be removed at some stage. + 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; + } + } else { + $name = null; + } + + return (object)['type' => $type, 'passsplat' => $passsplat, 'name' => $name, + 'rem' => trim(substr($text, $this->nexts[0]->startpos)), + 'fixed' => $type ? $this->getFixed() : null, 'complex' => $this->complex]; + } + + /** + * Parse a template + * @param ?object{namespace: string, uses: string[], templates: string[], classname: ?string, parentname: ?string} $scope + * @param string $text the text to parse + * @return object{type: ?non-empty-string, name: ?non-empty-string, rem: string, fixed: ?string, complex: bool} + * the simplified type, template name, remaining text, fixed text, and whether complex + */ + public function parseTemplate(?object $scope, string $text): object { + + // Initialise variables. + if ($scope) { + $this->scope = $scope; + } else { + $this->scope = (object)['namespace' => '', 'uses' => [], 'templates' => [], 'classname' => null, 'parentname' => null]; + } + $this->text = $text; + $this->replacements = []; + $this->gowide = false; + $this->complex = false; + $this->nexts = []; + $this->next = $this->next(); + + // Try to parse template name. + $savednexts = $this->nexts; + try { + if (!($this->next != null && (ctype_alpha($this->next[0]) || $this->next[0] == '_'))) { + throw new \Exception("Error parsing type, expected variable, saw \"{$this->next}\"."); + } + $name = $this->parseToken(); + if ( + !($this->next == null + || ctype_space(substr($this->text, $this->nexts[0]->startpos - 1, 1)) + || in_array($this->next, [',', ';', ':', '.'])) + ) { + // Code smell check. + throw new \Exception("Warning parsing type, no space after variable 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)) + || in_array($this->next, [',', ';', ':', '.'])) + ) { + // Code smell check. + throw new \Exception("Warning parsing type, no space after type."); + } + } catch (\Exception $e) { + $this->nexts = $savednexts; + $this->next = $this->next(); + $type = null; + } + } else { + $type = 'mixed'; + } + + return (object)['type' => $type, 'name' => $name, + 'rem' => trim(substr($text, $this->nexts[0]->startpos)), + 'fixed' => $type ? $this->getFixed() : null, 'complex' => $this->complex]; + } + + /** + * Compare types + * @param ?non-empty-string $widetype the type that should be wider, e.g. PHP type + * @param ?non-empty-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(?string $widetype, ?string $narrowtype): bool { + if ($narrowtype == null) { + return false; + } elseif ($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)) { + $haveallsingles = false; + break; + } + } + if ($haveallsingles) { + $havethisintersection = true; + break; + } + } + if (!$havethisintersection) { + $haveallintersections = false; + break; + } + } + return $haveallintersections; + } + + /** + * Get super types + * @param non-empty-string $basetype + * @return non-empty-string[] super types + */ + protected function superTypes(string $basetype): array { + if (in_array($basetype, ['int', 'string'])) { + $supertypes = ['array-key', 'scaler']; + } elseif ($basetype == 'callable-string') { + $supertypes = ['callable', 'string', 'array-key', 'scalar']; + } elseif (in_array($basetype, ['array-key', 'float', 'bool'])) { + $supertypes = ['scalar']; + } elseif ($basetype == 'array') { + $supertypes = ['iterable']; + } elseif ($basetype == 'static') { + $supertypes = ['self', 'parent', 'object']; + } elseif ($basetype == 'self') { + $supertypes = ['parent', 'object']; + } elseif ($basetype == 'parent') { + $supertypes = ['object']; + } elseif (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)) { + if (in_array($supertype, $supertypes)) { + $ignore = false; + continue; + } + if (!$ignore) { + $supertypes[] = $supertype; + } + if ($librarysupers = $this->library[$supertype] ?? null) { + $supertypequeue = array_merge($supertypequeue, $librarysupers); + } elseif ($supertypeobj = $this->artifacts[$supertype] ?? null) { + if ($supertypeobj->extends) { + $supertypequeue[] = $supertypeobj->extends; + } + if (count($supertypeobj->implements) > 0) { + foreach ($supertypeobj->implements as $implements) { + $supertypequeue[] = $implements; + } + } + } elseif (!$ignore) { + $supertypes = array_merge($supertypes, $this->superTypes($supertype)); + } + $ignore = false; + } + $supertypes = array_unique($supertypes); + } else { + $supertypes = []; + } + return $supertypes; + } + + /** + * Prefetch next token + * @param non-negative-int $lookahead + * @return ?non-empty-string + * @phpstan-impure + */ + protected function next(int $lookahead = 0): ?string { + + // Fetch any more tokens we need. + while (count($this->nexts) < $lookahead + 1) { + $startpos = $this->nexts ? end($this->nexts)->endpos : 0; + $stringunterminated = false; + + // Ignore whitespace. + while ($startpos < strlen($this->text) && ctype_space($this->text[$startpos])) { + $startpos++; + } + + $firstchar = ($startpos < strlen($this->text)) ? $this->text[$startpos] : null; + + // Deal with different types of tokens. + if ($firstchar == null) { + // No more tokens. + $endpos = $startpos; + } elseif ( + ctype_alpha($firstchar) || $firstchar == '_' || $firstchar == '$' || $firstchar == "\\" + || ord($firstchar) >= 0x7F && ord($firstchar) <= 0xFF + ) { + // Identifier token. + $endpos = $startpos; + do { + $endpos = $endpos + 1; + $nextchar = ($endpos < strlen($this->text)) ? $this->text[$endpos] : null; + } while ( + $nextchar != null && (ctype_alnum($nextchar) || $nextchar == '_' + || ord($nextchar) >= 0x7F && ord($nextchar) <= 0xFF + || $firstchar != '$' && ($nextchar == '-' || $nextchar == "\\")) + ); + } elseif ( + ctype_digit($firstchar) + || $firstchar == '-' && strlen($this->text) >= $startpos + 2 && ctype_digit($this->text[$startpos + 1]) + ) { + // Number token. + $nextchar = $firstchar; + $havepoint = false; + $endpos = $startpos; + do { + $havepoint = $havepoint || $nextchar == '.'; + $endpos = $endpos + 1; + $nextchar = ($endpos < strlen($this->text)) ? $this->text[$endpos] : null; + } while ($nextchar != null && (ctype_digit($nextchar) || $nextchar == '.' && !$havepoint || $nextchar == '_')); + } elseif ($firstchar == '"' || $firstchar == "'") { + // String token. + $endpos = $startpos + 1; + $nextchar = ($endpos < strlen($this->text)) ? $this->text[$endpos] : null; + while ($nextchar != $firstchar && $nextchar != null) { // There may be unterminated strings. + if ($nextchar == "\\" && strlen($this->text) >= $endpos + 2) { + $endpos = $endpos + 2; + } else { + $endpos++; + } + $nextchar = ($endpos < strlen($this->text)) ? $this->text[$endpos] : null; + } + if ($nextchar != null) { + $endpos++; + } else { + $stringunterminated = true; + } + } elseif (strlen($this->text) >= $startpos + 3 && substr($this->text, $startpos, 3) == '...') { + // Splat. + $endpos = $startpos + 3; + } elseif (strlen($this->text) >= $startpos + 2 && substr($this->text, $startpos, 2) == '::') { + // Scope resolution operator. + $endpos = $startpos + 2; + } else { + // Other symbol token. + $endpos = $startpos + 1; + } + + // Store token. + $next = substr($this->text, $startpos, $endpos - $startpos); + assert($next !== false); + if ($stringunterminated) { + // If we have an unterminated string, we've reached the end of usable tokens. + $next = ''; + } + $this->nexts[] = (object)['startpos' => $startpos, 'endpos' => $endpos, + 'text' => ($next !== '') ? $next : null, ]; + } + + // Return the needed token. + return $this->nexts[$lookahead]->text; + } + + /** + * Fetch the next token + * @param ?non-empty-string $expect the expected text, or null for any + * @return non-empty-string + * @phpstan-impure + */ + protected function parseToken(?string $expect = null): string { + + $next = $this->next; + + // Check we have the expected token. + if ($next == null) { + throw new \Exception("Error parsing type, unexpected end."); + } elseif ($expect != null && strtolower($next) != strtolower($expect)) { + throw new \Exception("Error parsing type, expected \"{$expect}\", saw \"{$next}\"."); + } + + // Prefetch next token. + $this->next(1); + + // Return consumed token. + array_shift($this->nexts); + $this->next = $this->next(); + return $next; + } + + /** + * Correct the next token + * @param non-empty-string $correct the corrected text + * @return void + * @phpstan-impure + */ + protected function correctToken(string $correct): void { + if ($correct != $this->nexts[0]->text) { + $this->replacements[] = + (object)['pos' => $this->nexts[0]->startpos, 'len' => strlen($this->nexts[0]->text), 'replacement' => $correct]; + } + } + + /** + * Get the corrected text, or null if no change + * @return ?string + */ + protected function getFixed(): ?string { + 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; + } + + /** + * 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 non-empty-string the simplified type + * @phpstan-impure + */ + protected function parseAnyType(bool $inbrackets = false): string { + + if ($inbrackets && $this->next !== null && $this->next[0] == '$' && $this->next(1) == 'is') { + // Conditional return type. + $this->complex = true; + $this->parseToken(); + $this->parseToken('is'); + $this->parseAnyType(); + $this->parseToken('?'); + $firsttype = $this->parseAnyType(); + $this->parseToken(':'); + $secondtype = $this->parseAnyType(); + $uniontypes = array_merge(explode('|', $firsttype), explode('|', $secondtype)); + } elseif ($this->next == '?') { + // Single nullable type. + $this->complex = true; + $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->gowide ? 'mixed' : 'never'; + $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) { + $this->parseToken('&'); + } + } while ($havemoreintersections); + if (count($intersectiontypes) > 1 && $unioninstead !== null) { + throw new \Exception("Error parsing type, non-DNF."); + } elseif (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']) + || in_array('object', $supertypes)) + ) { + throw new \Exception("Error parsing type, 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]); + } + } + array_push($uniontypes, implode('&', $intersectiontypes)); + } + // Check for more union items. + $havemoreunions = $this->next == '|'; + if ($havemoreunions) { + $this->parseToken('|'); + } + } while ($havemoreunions); + } + + // Tidy and return union list. + if (count($uniontypes) > 1) { + if (in_array('int', $uniontypes) && in_array('string', $uniontypes)) { + $uniontypes[] = 'array-key'; + } + if (in_array('bool', $uniontypes) && in_array('float', $uniontypes) && in_array('array-key', $uniontypes)) { + $uniontypes[] = 'scalar'; + } + if (in_array("\\Traversable", $uniontypes) && in_array('array', $uniontypes)) { + $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 $uniontype) { + assert($uniontype != ''); + foreach ($uniontypes as $key => $uniontype2) { + assert($uniontype2 != ''); + if ($uniontype2 != $uniontype && $this->compareTypes($uniontype, $uniontype2)) { + unset($uniontypes[$key]); + } + } + } + } + $type = implode('|', $uniontypes); + assert($type != ''); + return $type; + } + + /** + * Parse a single type, possibly array type + * @return non-empty-string the simplified type + * @phpstan-impure + */ + protected function parseSingleType(): string { + if ($this->next == '(') { + $this->parseToken('('); + $type = $this->parseAnyType(true); + $this->parseToken(')'); + } else { + $type = $this->parseBasicType(); + } + while ($this->next == '[' && $this->next(1) == ']') { + // Array suffix. + $this->parseToken('['); + $this->parseToken(']'); + $type = 'array'; + } + return $type; + } + + /** + * Parse a basic type + * @return non-empty-string the simplified type + * @phpstan-impure + */ + protected function parseBasicType(): string { + + $next = $this->next; + if ($next == null) { + throw new \Exception("Error parsing type, expected type, saw end."); + } + $lowernext = strtolower($next); + $nextchar = $next[0]; + + if (in_array($lowernext, ['bool', 'boolean', 'true', 'false'])) { + // Bool. + $this->correctToken(($lowernext == 'boolean') ? 'bool' : $lowernext); + $this->parseToken(); + $type = 'bool'; + } elseif ( + in_array($lowernext, ['int', 'integer', 'positive-int', 'negative-int', + 'non-positive-int', 'non-negative-int', + 'int-mask', 'int-mask-of', ]) + || (ctype_digit($nextchar) || $nextchar == '-') && strpos($next, '.') === false + ) { + // Int. + if (!in_array($lowernext, ['int', 'integer'])) { + $this->complex = true; + } + $this->correctToken(($lowernext == 'integer') ? 'int' : $lowernext); + $inttype = strtolower($this->parseToken()); + if ($inttype == 'int' && $this->next == '<') { + // Integer range. + $this->complex = true; + $this->parseToken('<'); + $next = $this->next; + if ( + $next == null + || !(strtolower($next) == 'min' + || (ctype_digit($next[0]) || $next[0] == '-') && strpos($next, '.') === false) + ) { + throw new \Exception("Error parsing type, expected int min, saw \"{$next}\"."); + } + $this->parseToken(); + $this->parseToken(','); + $next = $this->next; + if ( + $next == null + || !(strtolower($next) == 'max' + || (ctype_digit($next[0]) || $next[0] == '-') && strpos($next, '.') === false) + ) { + throw new \Exception("Error parsing type, expected int max, saw \"{$next}\"."); + } + $this->parseToken(); + $this->parseToken('>'); + } elseif ($inttype == 'int-mask') { + // Integer mask. + $this->parseToken('<'); + do { + $mask = $this->parseBasicType(); + if (!$this->compareTypes('int', $mask)) { + throw new \Exception("Error parsing type, invalid int mask."); + } + $haveseperator = $this->next == ','; + if ($haveseperator) { + $this->parseToken(','); + } + } while ($haveseperator); + $this->parseToken('>'); + } elseif ($inttype == 'int-mask-of') { + // Integer mask of. + $this->parseToken('<'); + $mask = $this->parseBasicType(); + if (!$this->compareTypes('int', $mask)) { + throw new \Exception("Error parsing type, invalid int mask."); + } + $this->parseToken('>'); + } + $type = 'int'; + } elseif ( + in_array($lowernext, ['float', 'double']) + || (ctype_digit($nextchar) || $nextchar == '-') && strpos($next, '.') !== false + ) { + // Float. + if (!in_array($lowernext, ['float', 'double'])) { + $this->complex = true; + } + $this->correctToken(($lowernext == 'double') ? 'float' : $lowernext); + $this->parseToken(); + $type = 'float'; + } elseif ( + in_array($lowernext, ['string', 'class-string', 'numeric-string', 'literal-string', + 'non-empty-string', 'non-falsy-string', 'truthy-string', ]) + || $nextchar == '"' || $nextchar == "'" + ) { + // String. + if ($lowernext != 'string') { + $this->complex = true; + } + if ($nextchar != '"' && $nextchar != "'") { + $this->correctToken($lowernext); + } + $strtype = strtolower($this->parseToken()); + if ($strtype == 'class-string' && $this->next == '<') { + $this->parseToken('<'); + $stringtype = $this->parseBasicType(); + if (!$this->compareTypes('object', $stringtype)) { + throw new \Exception("Error parsing type, class-string type isn't class."); + } + $this->parseToken('>'); + } + $type = 'string'; + } elseif ($lowernext == 'callable-string') { + // Callable-string. + $this->complex = true; + $this->correctToken($lowernext); + $this->parseToken('callable-string'); + $type = 'callable-string'; + } elseif (in_array($lowernext, ['array', 'non-empty-array', 'list', 'non-empty-list'])) { + // Array. + if ($lowernext != 'array') { + $this->complex = true; + } + $this->correctToken($lowernext); + $arraytype = strtolower($this->parseToken()); + if ($this->next == '<') { + // Typed array. + $this->complex = true; + $this->parseToken('<'); + $firsttype = $this->parseAnyType(); + if ($this->next == ',') { + if (in_array($arraytype, ['list', 'non-empty-list'])) { + throw new \Exception("Error parsing type, lists cannot have keys specified."); + } + $key = $firsttype; + if (!$this->compareTypes('array-key', $key)) { + throw new \Exception("Error parsing type, invalid array key."); + } + $this->parseToken(','); + $value = $this->parseAnyType(); + } else { + $key = null; + $value = $firsttype; + } + $this->parseToken('>'); + } elseif ($this->next == '{') { + // Array shape. + $this->complex = true; + if (in_array($arraytype, ['non-empty-array', 'non-empty-list'])) { + throw new \Exception("Error parsing type, non-empty-arrays cannot have shapes."); + } + $this->parseToken('{'); + do { + $next = $this->next; + if ( + $next != null + && (ctype_alpha($next) || $next[0] == '_' || $next[0] == "'" || $next[0] == '"' + || (ctype_digit($next[0]) || $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) { + $this->parseToken(','); + } + } while ($havecomma); + $this->parseToken('}'); + } + $type = 'array'; + } elseif ($lowernext == 'object') { + // Object. + $this->correctToken($lowernext); + $this->parseToken('object'); + if ($this->next == '{') { + // Object shape. + $this->complex = true; + $this->parseToken('{'); + do { + $next = $this->next; + if ( + $next == null + || !(ctype_alpha($next) || $next[0] == '_' || $next[0] == "'" || $next[0] == '"') + ) { + throw new \Exception("Error parsing type, invalid object key."); + } + $this->parseToken(); + if ($this->next == '?') { + $this->parseToken('?'); + } + $this->parseToken(':'); + $this->parseAnyType(); + $havecomma = $this->next == ','; + if ($havecomma) { + $this->parseToken(','); + } + } while ($havecomma); + $this->parseToken('}'); + } + $type = 'object'; + } elseif ($lowernext == 'resource') { + // Resource. + $this->correctToken($lowernext); + $this->parseToken('resource'); + $type = 'resource'; + } elseif (in_array($lowernext, ['never', 'never-return', 'never-returns', 'no-return'])) { + // Never. + $this->correctToken('never'); + $this->parseToken(); + $type = 'never'; + } elseif ($lowernext == 'null') { + // Null. + $this->correctToken($lowernext); + $this->parseToken('null'); + $type = 'null'; + } elseif ($lowernext == 'void') { + // Void. + $this->correctToken($lowernext); + $this->parseToken('void'); + $type = 'void'; + } elseif ($lowernext == 'self') { + // Self. + $this->correctToken($lowernext); + $this->parseToken('self'); + $type = $this->scope->classname ? $this->scope->classname : 'self'; + } elseif ($lowernext == 'parent') { + // Parent. + $this->complex = true; + $this->correctToken($lowernext); + $this->parseToken('parent'); + $type = $this->scope->parentname ? $this->scope->parentname : 'parent'; + } elseif (in_array($lowernext, ['static', '$this'])) { + // Static. + $this->correctToken($lowernext); + $this->parseToken(); + $type = $this->scope->classname ? "static({$this->scope->classname})" : 'static'; + } elseif ( + $lowernext == 'callable' + || $next == "\\Closure" || $next == 'Closure' && $this->scope->namespace == '' + ) { + // Callable. + if ($lowernext == 'callable') { + $this->correctToken($lowernext); + } + $callabletype = $this->parseToken(); + if ($this->next == '(') { + $this->complex = true; + $this->parseToken('('); + while ($this->next != ')') { + $this->parseAnyType(); + if ($this->next == '&') { + $this->parseToken('&'); + } + if ($this->next == '...') { + $this->parseToken('...'); + } + if ($this->next == '=') { + $this->parseToken('='); + } + $nextchar = ($this->next != null) ? $this->next[0] : null; + if ($nextchar == '$') { + $this->parseToken(); + } + if ($this->next != ')') { + $this->parseToken(','); + } + } + $this->parseToken(')'); + $this->parseToken(':'); + if ($this->next == '?') { + $this->parseAnyType(); + } else { + $this->parseSingleType(); + } + } + if (strtolower($callabletype) == 'callable') { + $type = 'callable'; + } else { + $type = "\\Closure"; + } + } elseif ($lowernext == 'mixed') { + // Mixed. + $this->correctToken($lowernext); + $this->parseToken('mixed'); + $type = 'mixed'; + } elseif ($lowernext == 'iterable') { + // Iterable (Traversable|array). + $this->correctToken($lowernext); + $this->parseToken('iterable'); + if ($this->next == '<') { + $this->complex = true; + $this->parseToken('<'); + $firsttype = $this->parseAnyType(); + if ($this->next == ',') { + $key = $firsttype; + $this->parseToken(','); + $value = $this->parseAnyType(); + } else { + $key = null; + $value = $firsttype; + } + $this->parseToken('>'); + } + $type = 'iterable'; + } elseif ($lowernext == 'array-key') { + // Array-key (int|string). + $this->complex = true; + $this->correctToken($lowernext); + $this->parseToken('array-key'); + $type = 'array-key'; + } elseif ($lowernext == 'scalar') { + // Scalar can be (bool|int|float|string). + $this->complex = true; + $this->correctToken($lowernext); + $this->parseToken('scalar'); + $type = 'scalar'; + } elseif ($lowernext == 'key-of') { + // Key-of. + $this->complex = true; + $this->correctToken($lowernext); + $this->parseToken('key-of'); + $this->parseToken('<'); + $iterable = $this->parseAnyType(); + if (!($this->compareTypes('iterable', $iterable) || $this->compareTypes('object', $iterable))) { + throw new \Exception("Error parsing type, can't get key of non-iterable."); + } + $this->parseToken('>'); + $type = $this->gowide ? 'mixed' : 'never'; + } elseif ($lowernext == 'value-of') { + // Value-of. + $this->complex = true; + $this->correctToken($lowernext); + $this->parseToken('value-of'); + $this->parseToken('<'); + $iterable = $this->parseAnyType(); + if (!($this->compareTypes('iterable', $iterable) || $this->compareTypes('object', $iterable))) { + throw new \Exception("Error parsing type, can't get value of non-iterable."); + } + $this->parseToken('>'); + $type = $this->gowide ? 'mixed' : 'never'; + } elseif ( + (ctype_alpha($next[0]) || $next[0] == '_' || $next[0] == "\\") + && strpos($next, '-') === false && strpos($next, "\\\\") === false + ) { + // Class name. + $type = $this->parseToken(); + if (strrpos($type, "\\") === strlen($type) - 1) { + throw new \Exception("Error parsing type, class name has trailing slash."); + } + if ($type[0] != "\\") { + if (array_key_exists($type, $this->scope->uses)) { + $type = $this->scope->uses[$type]; + } elseif (array_key_exists($type, $this->scope->templates)) { + $type = $this->scope->templates[$type]; + } else { + $type = $this->scope->namespace . "\\" . $type; + } + assert($type != ''); + } + } else { + throw new \Exception("Error parsing type, unrecognised type."); + } + + // 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)))) { + // Generics. + $this->complex = true; + $this->parseToken('<'); + $more = false; + do { + $this->parseAnyType(); + $more = ($this->next == ','); + if ($more) { + $this->parseToken(','); + } + } while ($more); + $this->parseToken('>'); + } elseif ($this->next == '::' && (in_array('object', $this->superTypes($type)))) { + // Class constant. + $this->complex = true; + $this->parseToken('::'); + $nextchar = ($this->next == null) ? null : $this->next[0]; + $haveconstantname = $nextchar != null && (ctype_alpha($nextchar) || $nextchar == '_'); + if ($haveconstantname) { + $this->parseToken(); + } + if ($this->next == '*' || !$haveconstantname) { + $this->parseToken('*'); + } + $type = $this->gowide ? 'mixed' : 'never'; + } + + return $type; + } +}