From 4d9517a0c22d0bce329deec43a965bb2192766d4 Mon Sep 17 00:00:00 2001 From: James C <5689414+james-cnz@users.noreply.github.com> Date: Sun, 17 Mar 2024 22:53:03 +1300 Subject: [PATCH 1/4] PHPDoc types sniff --- moodle/Sniffs/Commenting/PHPDocTypesSniff.php | 960 +++++++++++++++ .../Commenting/PHPDocTypesSniffTest.php | 119 ++ .../fixtures/phpdoctypes_general_right.php | 99 ++ .../phpdoctypes_method_union_types_right.php | 92 ++ .../fixtures/phpdoctypes_properties_right.php | 45 + .../fixtures/phpdoctypes_properties_wrong.php | 35 + .../phpdoctypes_tags_general_right.php | 138 +++ .../phpdoctypes_tags_general_wrong.php | 133 +++ moodle/Tests/Util/PHPDocTypeParserTest.php | 102 ++ .../phpdoctypes_all_types_right.php | 432 +++++++ .../phpdoctypes/phpdoctypes_parse_wrong.php | 131 ++ moodle/Util/PHPDocTypeParser.php | 1053 +++++++++++++++++ 12 files changed, 3339 insertions(+) create mode 100644 moodle/Sniffs/Commenting/PHPDocTypesSniff.php create mode 100644 moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_general_right.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_method_union_types_right.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_right.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_wrong.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_right.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_wrong.php create mode 100644 moodle/Tests/Util/PHPDocTypeParserTest.php create mode 100644 moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php create mode 100644 moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php create mode 100644 moodle/Util/PHPDocTypeParser.php diff --git a/moodle/Sniffs/Commenting/PHPDocTypesSniff.php b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php new file mode 100644 index 0000000..630df1f --- /dev/null +++ b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php @@ -0,0 +1,960 @@ +. + +/** + * Check PHPDoc Types. + * + * @copyright 2024 Otago Polytechnic + * @author James Calder + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later (or CC BY-SA v4 or later) + */ + +namespace MoodleHQ\MoodleCS\moodle\Sniffs\Commenting; + +use PHP_CodeSniffer\Sniffs\Sniff; +use PHP_CodeSniffer\Files\File; +use MoodleHQ\MoodleCS\moodle\Util\PHPDocTypeParser; + +/** + * Check PHPDoc Types. + */ +class PHPDocTypesSniff implements Sniff +{ + /** @var ?File the current file */ + protected ?File $file = null; + + /** @var array{'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int}[] + * file tokens */ + protected array $tokens = []; + + /** @var array + * classish things: classes, interfaces, traits, and enums */ + protected array $artifacts = []; + + /** @var ?PHPDocTypeParser */ + 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 non-empty-array<\stdClass&object{type: string, namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, opened: bool, closer: ?int}> + * file scope: classish, function, etc. We only need a closer if we might be in a switch statement. */ + protected array $scopes; + + /** @var ?(\stdClass&object{tags: array}) PHPDoc comment for upcoming declaration */ + protected ?object $commentpending = null; + + /** @var int how long until we dispose of a pending comment */ + protected int $commentpendingcounter = 0; + + /** @var ?(\stdClass&object{tags: array}) PHPDoc comment for current declaration */ + protected ?object $comment = null; + + /** @var array{'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int} + * the current token */ + protected array $token = ['code' => null, 'content' => '']; + + /** @var array{'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int} + * the previous token */ + protected array $tokenprevious = ['code' => null, 'content' => '']; + + /** + * Register for open tag (only process once per file). + * @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 void + */ + public function process(File $phpcsfile, $stackptr): void { + + // Check we haven't already done this file. + if ($phpcsfile == $this->file) { + return; + } + + try { + $this->file = $phpcsfile; + $this->tokens = $phpcsfile->getTokens(); + $this->artifacts = []; + + // Gather atifact info. + $this->pass = 1; + $this->typeparser = null; + $this->fileptr = $stackptr; + $this->processPass(); + + // Check the PHPDoc types. + $this->pass = 2; + $this->typeparser = new PHPDocTypeParser($this->artifacts); + $this->fileptr = $stackptr; + $this->processPass(); + } catch (\Exception $e) { + $this->file->addError( + 'The PHPDoc type sniff failed to parse the file. PHPDoc type checks were not performed.', + $this->fileptr < count($this->tokens) ? $this->fileptr : $this->fileptr - 1, + 'phpdoc_type_parse' + ); + } + } + + /** + * A pass over the file. + * @return void + * @phpstan-impure + */ + protected function processPass(): void { + $this->scopes = [(object)['type' => 'root', 'namespace' => '', 'uses' => [], 'templates' => [], + 'classname' => null, 'parentname' => null, 'opened' => true, 'closer' => null]]; + $this->tokenprevious = ['code' => null, 'content' => '']; + $this->fetchToken(); + $this->commentpending = null; + $this->comment = null; + + while ($this->token['code']) { + // Skip irrelevant tokens. + while ( + !in_array( + $this->token['code'], + [T_NAMESPACE, T_USE, + T_ABSTRACT, T_PUBLIC, T_PROTECTED, T_PRIVATE, T_STATIC, T_READONLY, T_FINAL, + T_CLASS, T_ANON_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, + T_FUNCTION, T_CLOSURE, T_VAR, T_CONST, + T_SEMICOLON, null] + ) + && (!isset($this->token['scope_opener']) || $this->token['scope_opener'] != $this->fileptr) + && (!isset($this->token['scope_closer']) || $this->token['scope_closer'] != $this->fileptr) + ) { + $this->advance(); + } + + // Check for the end of the file. + if (!$this->token['code']) { + break; + } + + // Namespace. + if ($this->token['code'] == T_NAMESPACE && end($this->scopes)->opened) { + $this->processNamespace(); + continue; + } + + // Use. + if ($this->token['code'] == T_USE) { + if (end($this->scopes)->type == 'classish' && end($this->scopes)->opened) { + $this->processClassTraitUse(); + } elseif (end($this->scopes)->type == 'function' && !end($this->scopes)->opened) { + $this->advance(T_USE); + } else { + $this->processUse(); + } + continue; + } + + // Ignore constructor property promotion. This has already been checked. + if ( + end($this->scopes)->type == 'function' && !end($this->scopes)->opened + && in_array($this->token['code'], [T_PUBLIC, T_PROTECTED, T_PRIVATE]) + ) { + $this->advance(); + continue; + } + + // Malformed prior declaration. + if ( + !end($this->scopes)->opened + && !(isset($this->token['scope_opener']) && $this->token['scope_opener'] == $this->fileptr + || $this->token['code'] == T_SEMICOLON) + ) { + throw new \Exception(); + } + + // Opening a scope. + if (isset($this->token['scope_opener']) && $this->token['scope_opener'] == $this->fileptr) { + if ($this->token['scope_closer'] == end($this->scopes)->closer) { + // We're closing the previous scope at the same time. This happens in switch statements. + if (count($this->scopes) <= 1) { + // Trying to close a scope that wasn't open. + throw new \Exception(); + } + array_pop($this->scopes); + } + if (!end($this->scopes)->opened) { + end($this->scopes)->opened = true; + } else { + $oldscope = end($this->scopes); + array_push($this->scopes, $newscope = clone $oldscope); + $newscope->type = 'other'; + $newscope->opened = true; + $newscope->closer = $this->tokens[$this->fileptr]['scope_closer']; + } + $this->advance(); + continue; + } + + // Closing a scope (without opening a new one). + if (isset($this->token['scope_closer']) && $this->token['scope_closer'] == $this->fileptr) { + if (count($this->scopes) <= 1) { + // Trying to close a scope that wasn't open. + throw new \Exception(); + } + array_pop($this->scopes); + $this->advance(); + continue; + } + + // Empty declarations and other semicolons. + if ($this->token['code'] == T_SEMICOLON) { + if (!end($this->scopes)->opened) { + array_pop($this->scopes); + } + $this->advance(T_SEMICOLON); + continue; + } + + // Declarations. + if ( + in_array( + $this->token['code'], + [T_ABSTRACT, T_PUBLIC, T_PROTECTED, T_PRIVATE, T_STATIC, T_READONLY, T_FINAL, + T_CLASS, T_ANON_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, + T_FUNCTION, T_CLOSURE, + T_CONST, T_VAR, ] + ) + ) { + // Fetch comment. + $this->comment = $this->commentpending; + $this->commentpending = null; + $static = false; + $staticprecededbynew = ($this->tokenprevious['code'] == T_NEW); + while ( + in_array( + $this->token['code'], + [T_ABSTRACT, T_PUBLIC, T_PROTECTED, T_PRIVATE, T_STATIC, T_READONLY, T_FINAL] + ) + ) { + $static = ($this->token['code'] == T_STATIC); + $this->advance(); + } + if ($static && ($this->token['code'] == T_DOUBLE_COLON || $staticprecededbynew)) { + // Ignore static late binding. + } elseif (in_array($this->token['code'], [T_CLASS, T_ANON_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) { + // Classish thing. + $this->processClassish(); + } elseif ($this->token['code'] == T_FUNCTION || $this->token['code'] == T_CLOSURE) { + // Function. + $this->processFunction(); + } else { + // Variable. + $this->processVariable(); + } + $this->comment = null; + continue; + } + + // We got something unrecognised. + throw new \Exception(); + } + + // Some scopes weren't closed. + if (count($this->scopes) != 1) { + throw new \Exception(); + } + } + + /** + * 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(); + } + + $nextptr = $this->fileptr + 1; + + // Skip stuff that doesn't effect us. + while ( + $nextptr < count($this->tokens) + && in_array($this->tokens[$nextptr]['code'], [T_WHITESPACE, T_COMMENT, T_INLINE_HTML, T_PHPCS_IGNORE]) + ) { + $nextptr++; + } + + $this->tokenprevious = $this->token; + + // Process PHPDoc comments. + while ($nextptr < count($this->tokens) && $this->tokens[$nextptr]['code'] == T_DOC_COMMENT_OPEN_TAG) { + $this->fileptr = $nextptr; + $this->fetchToken(); + $this->processComment(); + $this->commentpendingcounter = 2; + $nextptr = $this->fileptr; + } + + $this->fileptr = $nextptr; + $this->fetchToken(); + + // Dispose of old comment. + if ($this->commentpending) { + $this->commentpendingcounter--; + if ($this->commentpendingcounter <= 0) { + $this->commentpending = null; + } + } + } + + /** + * Advance the token pointer when reading PHPDoc comments. + * @param array-key $expectedcode What we expect, or null if anything's OK + * @return void + * @phpstan-impure + */ + protected function advanceComment($expectedcode = null): void { + + // Check we are actually in a PHPDoc comment. + if ( + !in_array( + $this->token['code'], + [T_DOC_COMMENT_OPEN_TAG, T_DOC_COMMENT_CLOSE_TAG, T_DOC_COMMENT_STAR, + T_DOC_COMMENT_TAG, T_DOC_COMMENT_STRING, T_DOC_COMMENT_WHITESPACE, + T_PHPCS_IGNORE] + ) + ) { + throw new \Exception(); + } + + // 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(); + } + + $this->fileptr++; + + // If we're expecting the end of the comment, then we need to advance to the next PHP code. + if ($expectedcode == T_DOC_COMMENT_CLOSE_TAG) { + while ( + $this->fileptr < count($this->tokens) + && in_array($this->tokens[$this->fileptr]['code'], [T_WHITESPACE, T_COMMENT, T_INLINE_HTML]) + ) { + $this->fileptr++; + } + } + + $this->fetchToken(); + } + + /** + * Process a PHPDoc comment. + * @return void + * @phpstan-impure + */ + protected function processComment(): void { + $this->commentpending = (object)['tags' => []]; + + // Skip 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->advanceComment(); + } + + // For each tag. + while ($this->token['code'] != T_DOC_COMMENT_CLOSE_TAG) { + // Fetch the tag type. + if ($this->token['code'] == T_DOC_COMMENT_TAG) { + $tagtype = $this->token['content']; + $this->advanceComment(T_DOC_COMMENT_TAG); + } else { + $tagtype = ''; + } + $tagcontent = ''; + + // For each line, until we reach a new tag. + do { + $newline = false; + // Fetch line content. + while ($this->token['code'] != T_DOC_COMMENT_CLOSE_TAG && !$newline) { + $tagcontent .= $this->token['content']; + $newline = in_array(substr($this->token['content'], -1), ["\n", "\r"]); + $this->advanceComment(); + } + // 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->advanceComment(); + } + } while (!in_array($this->token['code'], [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][] = trim($tagcontent); + } + $this->advanceComment(T_DOC_COMMENT_CLOSE_TAG); + } + + /** + * Process a namespace declaration. + * @return void + * @phpstan-impure + */ + protected function processNamespace(): void { + $this->advance(T_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(); + } + if ($namespace != '' && $namespace[strlen($namespace) - 1] == "\\") { + throw new \Exception(); + } + if ($namespace != '' && $namespace[0] != "\\") { + $namespace = "\\" . $namespace; + } + if (!in_array($this->token['code'], [T_OPEN_CURLY_BRACKET, T_SEMICOLON])) { + throw new \Exception(); + } + if ($this->token['code'] == T_SEMICOLON) { + end($this->scopes)->namespace = $namespace; + } else { + $oldscope = end($this->scopes); + array_push($this->scopes, $newscope = clone $oldscope); + $newscope->type = 'namespace'; + $newscope->namespace = $namespace; + $newscope->opened = false; + $newscope->closer = null; + } + } + + /** + * Process a use declaration. + * @return void + * @phpstan-impure + */ + protected function processUse(): void { + $this->advance(T_USE); + $more = false; + do { + $namespace = ''; + $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); + } + 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(); + } + if ($namespace != '' && $namespace[0] != "\\") { + $namespace = "\\" . $namespace; + } + if ($this->token['code'] == T_OPEN_USE_GROUP) { + $namespacestart = $namespace; + if ($namespacestart && strrpos($namespacestart, "\\") != strlen($namespacestart) - 1) { + throw new \Exception(); + } + $typestart = $type; + $this->advance(T_OPEN_USE_GROUP); + do { + $namespaceend = ''; + $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); + } + while ( + in_array( + $this->token['code'], + [T_NAME_FULLY_QUALIFIED, T_NAME_QUALIFIED, T_NAME_RELATIVE, T_NS_SEPARATOR, T_STRING] + ) + ) { + $namespaceend .= $this->token['content']; + $this->advance(); + } + $namespace = $namespacestart . $namespaceend; + $alias = substr($namespace, strrpos($namespace, "\\") + 1); + $asalias = $this->processUseAsAlias(); + $alias = $asalias ?? $alias; + if ($this->pass == 2 && $type == 'class') { + end($this->scopes)->uses[$alias] = $namespace; + } + $more = ($this->token['code'] == T_COMMA); + if ($more) { + $this->advance(T_COMMA); + } + } while ($more); + $this->advance(T_CLOSE_USE_GROUP); + } else { + $alias = (strrpos($namespace, "\\") !== false) ? + substr($namespace, strrpos($namespace, "\\") + 1) + : $namespace; + if ($alias == '') { + throw new \Exception(); + } + $asalias = $this->processUseAsAlias(); + $alias = $asalias ?? $alias; + if ($this->pass == 2 && $type == 'class') { + end($this->scopes)->uses[$alias] = $namespace; + } + } + $more = ($this->token['code'] == T_COMMA); + if ($more) { + $this->advance(T_COMMA); + } + } while ($more); + if ($this->token['code'] != T_SEMICOLON) { + throw new \Exception(); + } + } + + /** + * 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); + if ($this->token['code'] == T_STRING) { + $alias = $this->token['content']; + $this->advance(T_STRING); + } + } + return $alias; + } + + /** + * Process a classish thing. + * @return void + * @phpstan-impure + */ + protected function processClassish(): void { + + // Get details. + $name = $this->file->getDeclarationName($this->fileptr); + $name = $name ? end($this->scopes)->namespace . "\\" . $name : null; + $parent = $this->file->findExtendedClassName($this->fileptr); + if ($parent && $parent[0] != "\\") { + $parent = end($this->scopes)->namespace . "\\" . $parent; + } + $interfaces = $this->file->findImplementedInterfaceNames($this->fileptr); + if (!is_array($interfaces)) { + $interfaces = []; + } + foreach ($interfaces as $index => $interface) { + if ($interface && $interface[0] != "\\") { + $interfaces[$index] = end($this->scopes)->namespace . "\\" . $interface; + } + } + + // Add to scopes. + $oldscope = end($this->scopes); + array_push($this->scopes, $newscope = clone $oldscope); + $newscope->type = 'classish'; + $newscope->classname = $name; + $newscope->parentname = $parent; + $newscope->opened = false; + $newscope->closer = null; + + if ($this->pass == 1 && $name) { + // Store details. + $this->artifacts[$name] = (object)['extends' => $parent, 'implements' => $interfaces]; + } elseif ($this->pass == 2) { + // Check and store templates. + if ($this->comment && isset($this->comment->tags['@template'])) { + $this->processTemplates(); + } + } + + $this->advance(); + } + + /** + * Process a class trait usage. + * @return void + * @phpstan-impure + */ + protected function processClassTraitUse(): void { + $this->advance(T_USE); + + 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) { + $this->advance(T_OPEN_CURLY_BRACKET); + do { + $this->advance(T_STRING); + if ($this->token['code'] == T_AS) { + $this->advance(T_AS); + while (in_array($this->token['code'], [T_PUBLIC, T_PROTECTED, T_PRIVATE])) { + $this->advance(); + } + if ($this->token['code'] == T_STRING) { + $this->advance(T_STRING); + } + } + if ($this->token['code'] == T_SEMICOLON) { + $this->advance(T_SEMICOLON); + } + } while ($this->token['code'] != T_CLOSE_CURLY_BRACKET); + $this->advance(T_CLOSE_CURLY_BRACKET); + } + } + + /** + * Process a function. + * @return void + * @phpstan-impure + */ + protected function processFunction(): void { + + // Get details. + $name = $this->file->getDeclarationName($this->fileptr); + $parameters = $this->file->getMethodParameters($this->fileptr); + $properties = $this->file->getMethodProperties($this->fileptr); + + // Push to scopes. + $oldscope = end($this->scopes); + array_push($this->scopes, $newscope = clone $oldscope); + $newscope->type = 'function'; + $newscope->opened = false; + $newscope->closer = null; + + // Checks. + if ($this->pass == 2) { + // Check for missing docs if not anonymous. + if ($name && !$this->comment) { + $this->file->addWarning( + 'PHPDoc function is not documented', + $this->fileptr, + 'phpdoc_fun_doc_missing' + ); + } + + // Check and store templates. + if ($this->comment && isset($this->comment->tags['@template'])) { + $this->processTemplates(); + } + + // Check parameter types. + if ($this->comment && isset($parameters)) { + if (!isset($this->comment->tags['@param'])) { + $this->comment->tags['@param'] = []; + } + if (count($this->comment->tags['@param']) != count($parameters)) { + $this->file->addError( + "PHPDoc number of function @param tags doesn't match actual number of parameters", + $this->fileptr, + 'phpdoc_fun_param_count' + ); + } + for ($varnum = 0; $varnum < count($this->comment->tags['@param']); $varnum++) { + $docparamdata = $this->typeparser->parseTypeAndVar( + $newscope, + $this->comment->tags['@param'][$varnum], + 2, + false + ); + if (!$docparamdata->type) { + $this->file->addError( + 'PHPDoc function parameter %s type missing or malformed', + $this->fileptr, + 'phpdoc_fun_param_type', + [$varnum + 1] + ); + } elseif (!$docparamdata->var) { + $this->file->addError( + 'PHPDoc function parameter %s name missing or malformed', + $this->fileptr, + 'phpdoc_fun_param_name', + [$varnum + 1] + ); + } elseif ($varnum < count($parameters)) { + $paramdata = $this->typeparser->parseTypeAndVar( + $newscope, + $parameters[$varnum]['content'], + 3, + true + ); + if (!$this->typeparser->comparetypes($paramdata->type, $docparamdata->type)) { + $this->file->addError( + 'PHPDoc function parameter %s type mismatch', + $this->fileptr, + 'phpdoc_fun_param_type_mismatch', + [$varnum + 1] + ); + } + if ($paramdata->passsplat != $docparamdata->passsplat) { + $this->file->addWarning( + 'PHPDoc function parameter %s splat mismatch', + $this->fileptr, + 'phpdoc_fun_param_pass_splat_mismatch', + [$varnum + 1] + ); + } + if ($paramdata->var != $docparamdata->var) { + $this->file->addError( + 'PHPDoc function parameter %s name mismatch', + $this->fileptr, + 'phpdoc_fun_param_name_mismatch', + [$varnum + 1] + ); + } + } + } + } + + // Check return type. + if ($this->comment && isset($properties)) { + if (!isset($this->comment->tags['@return'])) { + $this->comment->tags['@return'] = []; + } + // The old checker didn't check this. + /*if (count($this->comment->tags['@return']) < 1 && $name != '__construct') { + $this->file->addError( + 'PHPDoc missing function @return tag', + $this->fileptr, + 'phpdoc_fun_ret_missing' + ); + } else*/ + if (count($this->comment->tags['@return']) > 1) { + $this->file->addError( + 'PHPDoc multiple function @return tags--Put in one tag, seperated by vertical bars |', + $this->fileptr, + 'phpdoc_fun_ret_multiple' + ); + } + $retdata = $properties['return_type'] ? + $this->typeparser->parseTypeAndVar( + $newscope, + $properties['return_type'], + 0, + true + ) + : (object)['type' => 'mixed']; + for ($retnum = 0; $retnum < count($this->comment->tags['@return']); $retnum++) { + $docretdata = $this->typeparser->parseTypeAndVar( + $newscope, + $this->comment->tags['@return'][$retnum], + 0, + false + ); + if (!$docretdata->type) { + $this->file->addError( + 'PHPDoc function return type missing or malformed', + $this->fileptr, + 'phpdoc_fun_ret_type' + ); + } elseif (!$this->typeparser->comparetypes($retdata->type, $docretdata->type)) { + $this->file->addError( + 'PHPDoc function return type mismatch', + $this->fileptr, + 'phpdoc_fun_ret_type_mismatch' + ); + } + } + } + } + + $this->advance(); + if ($this->token['code'] == T_BITWISE_AND) { + $this->advance(T_BITWISE_AND); + } + + // Function name. + if ($this->token['code'] == T_STRING) { + $this->advance(T_STRING); + } + + // Parameters. + if ($this->token['code'] != T_OPEN_PARENTHESIS) { + throw new \Exception(); + } + } + + /** + * Process templates. + * @return void + * @phpstan-impure + */ + protected function processTemplates(): void { + $newscope = end($this->scopes); + foreach ($this->comment->tags['@template'] as $templatetext) { + $templatedata = $this->typeparser->parseTemplate($newscope, $templatetext); + if (!$templatedata->var) { + $this->file->addError('PHPDoc template name missing or malformed', $this->fileptr, 'phpdoc_template_name'); + } elseif (!$templatedata->type) { + $this->file->addError('PHPDoc template type missing or malformed', $this->fileptr, 'phpdoc_template_type'); + $newscope->templates[$templatedata->var] = 'never'; + } else { + $newscope->templates[$templatedata->var] = $templatedata->type; + } + } + } + + /** + * Process a variable. + * @return void + * @phpstan-impure + */ + protected function processVariable(): 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) { + 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(); + } + + // Checking. + if ($this->pass == 2) { + // Get properties, unless it's a function static variable or constant. + $properties = (end($this->scopes)->type == 'classish' && !$const) ? + $this->file->getMemberProperties($this->fileptr) + : null; + + if (!$this->comment && end($this->scopes)->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 ($this->comment) { + if (!isset($this->comment->tags['@var'])) { + $this->comment->tags['@var'] = []; + } + if (count($this->comment->tags['@var']) < 1) { + $this->file->addError('PHPDoc missing @var tag', $this->fileptr, 'phpdoc_var_missing'); + } elseif (count($this->comment->tags['@var']) > 1) { + $this->file->addError('PHPDoc multiple @var tags', $this->fileptr, 'phpdoc_var_multiple'); + } + $vardata = ($properties && $properties['type']) ? + $this->typeparser->parseTypeAndVar( + end($this->scopes), + $properties['type'], + 0, + true + ) + : (object)['type' => 'mixed']; + for ($varnum = 0; $varnum < count($this->comment->tags['@var']); $varnum++) { + $docvardata = $this->typeparser->parseTypeAndVar( + end($this->scopes), + $this->comment->tags['@var'][$varnum], + 0, + false + ); + if (!$docvardata->type) { + $this->file->addError( + 'PHPDoc var type missing or malformed', + $this->fileptr, + 'phpdoc_var_type', + [$varnum + 1] + ); + } elseif (!$this->typeparser->comparetypes($vardata->type, $docvardata->type)) { + $this->file->addError( + 'PHPDoc var type mismatch', + $this->fileptr, + 'phpdoc_var_type_mismatch' + ); + } + } + } + } + + $this->advance(); + + if (!in_array($this->token['code'], [T_EQUAL, T_COMMA, T_SEMICOLON])) { + throw new \Exception(); + } + } +} diff --git a/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php new file mode 100644 index 0000000..a15991c --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php @@ -0,0 +1,119 @@ +. + +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 general right' => [ + 'fixture' => 'phpdoctypes_general_right', + 'errors' => [], + 'warnings' => [], + ], + 'PHPDocTypes method union types right' => [ + 'fixture' => 'phpdoctypes_method_union_types_right', + 'errors' => [], + 'warnings' => [], + ], + 'PHPDocTypes properties right' => [ + 'fixture' => 'phpdoctypes_properties_right', + 'errors' => [], + 'warnings' => [], + ], + 'PHPDocTypes properties wrong' => [ + 'fixture' => 'phpdoctypes_properties_wrong', + 'errors' => [ + 33 => 'PHPDoc missing @var tag', + ], + 'warnings' => [ + 23 => 'PHPDoc variable or constant is not documented', + 24 => 'PHPDoc variable or constant is not documented', + 25 => 'PHPDoc variable or constant is not documented', + 26 => 'PHPDoc variable or constant is not documented', + 27 => 'PHPDoc variable or constant is not documented', + 28 => 'PHPDoc variable or constant is not documented', + ], + ], + 'PHPDocTypes tags general right' => [ + 'fixture' => 'phpdoctypes_tags_general_right', + 'errors' => [], + 'warnings' => [], + ], + 'PHPDocTypes tags general wrong' => [ + 'fixture' => 'phpdoctypes_tags_general_wrong', + 'errors' => [ + 44 => 2, + 54 => "PHPDoc number of function @param tags doesn't match actual number of parameters", + 61 => "PHPDoc number of function @param tags doesn't match actual number of parameters", + 71 => "PHPDoc number of function @param tags doesn't match actual number of parameters", + 80 => "PHPDoc number of function @param tags doesn't match actual number of parameters", + 90 => 'PHPDoc function parameter 2 type mismatch', + 100 => 'PHPDoc function parameter 1 type mismatch', + 110 => 'PHPDoc function parameter 1 type mismatch', + 120 => 'PHPDoc function parameter 2 type mismatch', + 129 => 'PHPDoc function return type missing or malformed', + ], + 'warnings' => [ + 110 => 'PHPDoc function parameter 2 splat mismatch', + ], + ], + ]; + } +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_general_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_general_right.php new file mode 100644 index 0000000..350b740 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_general_right.php @@ -0,0 +1,99 @@ +. + +/** + * 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 (or CC BY-SA v4 or later) + */ + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures; + +defined('MOODLE_INTERNAL') || die(); + +use stdClass as myStdClass; + +/** + * A parent class + */ +class php_valid_parent { +} + +/** + * An interface + */ +interface php_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 (or CC BY-SA v4 or later) + */ +class php_valid extends php_valid_parent implements php_valid_interface { + /** + * Namespaces recognised + * @param \MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures\php_valid $x + * @return void + */ + function namespaces(php_valid $x): void { + } + + /** + * Uses recognised + * @param \stdClass $x + * @return void + */ + function uses(myStdClass $x): void { + } + + /** + * Parents recognised + * @param php_valid $x + * @return void + */ + function parents(php_valid_parent $x): void { + } + + /** + * Interfaces recognised + * @param php_valid $x + * @return void + */ + function interfaces(php_valid_interface $x): void { + } + + /** + * Multiline comment + * @param object{ + * a: int, + * b: string + * } $x + * @return void + */ + function multiline_comment(object $x): void { + } +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_method_union_types_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_method_union_types_right.php new file mode 100644 index 0000000..61fbb7f --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_method_union_types_right.php @@ -0,0 +1,92 @@ +. + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures; + +/** + * A fixture to verify various phpdoc tags in a general location. + * + * @package local_moodlecheck + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class union_types { + /** + * An example of a method on a single line using union types in both the params and return values + * @param string|int $value + * @return string|int + */ + public function method_oneline(string|int $value): string|int { + // Do something. + return $value; + } + + /** + * An example of a method on a single line using union types in both the params and return values + * + * @param string|int $value + * @param int|float $othervalue + * @return string|int + */ + public function method_oneline_multi(string|int $value, int|float $othervalue): string|int { + // Do something. + return $value; + } + + /** + * An example of a method on a single line using union types in both the params and return values + * + * @param string|int $value + * @param int|float $othervalue + * @return string|int + */ + public function method_multiline( + string|int $value, + int|float $othervalue, + ): string|int { + // Do something. + return $value; + } + + /** + * An example of a method whose union values are not in the same order. + + * @param int|string $value + * @param int|float $othervalue + * @return int|string + */ + public function method_union_order_does_not_matter( + string|int $value, + float|int $othervalue, + ): string|int { + // Do something. + return $value; + } + + /** + * An example of a method which uses strings, or an array of strings. + * + * @param string|string[] $arrayofstrings + * @return string[]|string + */ + public function method_union_containing_array( + string|array $arrayofstrings, + ): string|array { + return [ + 'example', + ]; + } +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_right.php new file mode 100644 index 0000000..216ed01 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_right.php @@ -0,0 +1,45 @@ +. + +defined('MOODLE_INTERNAL') || die(); + +/** + * A dummy class for tests of rules involving properties. + */ +class dummy_with_properties { + + /** + * @var mixed $documented1 I'm just a dummy! + */ + var $documented1; + /** + * @var ?string $documented2 I'm just a dummy! + */ + var mixed $documented2; + /** + * @var mixed $documented3 I'm just a dummy! + */ + private $documented3; + /** + * @var ?string $documented4 I'm just a dummy! + */ + private ?string $documented4; + + /** + * @var A correctly documented constant. + */ + const CORRECTLY_DOCUMENTED_CONSTANT = 0; +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_wrong.php new file mode 100644 index 0000000..0b14efa --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_wrong.php @@ -0,0 +1,35 @@ +. + +defined('MOODLE_INTERNAL') || die(); + +/** + * A dummy class for tests of rules involving properties. + */ +class dummy_with_properties { + var $undocumented1; + var ?string $undocumented2; + private $undocumented3; + private ?string $undocumented4; + const UNDOCUMENTED_CONSTANT1 = 0; + public const UNDOCUMENTED_CONSTANT2 = 0; + + /** + * @const A wrongly documented constant. + */ + const WRONGLY_DOCUMENTED_CONSTANT = 0; + +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_right.php new file mode 100644 index 0000000..7110351 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_right.php @@ -0,0 +1,138 @@ +. + +/** + * A fixture to verify various phpdoc tags in a general location. + * + * @package local_moodlecheck + * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +/** + * A fixture to verify various phpdoc tags in a general location. + * + * @package local_moodlecheck + * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class fixturing_general { + + /** + * Correct param types. + * + * @param string|bool $one + * @param bool $two + * @param array $three + */ + public function correct_param_types($one, bool $two, array $three): void { + echo "yay!"; + } + + /** + * Correct param types. + * + * @param string|bool $one + * @param bool $two + * @param array $three + */ + public function correct_param_types1($one, bool $two, array $three): void { + echo "yay!"; + } + + /** + * Correct param types. + * + * @param string $one + * @param bool $two + */ + public function correct_param_types2($one, $two): void { + echo "yay!"; + } + + /** + * Correct param types. + * + * @param string|null $one + * @param bool $two + * @param array $three + */ + public function correct_param_types3(?string $one, bool $two, array $three): void { + echo "yay!"; + } + + /** + * Correct param types. + * + * @param string $one + * @param bool $two + * @param int[]|null $three + */ + public function correct_param_types4($one, bool $two, array $three = null): void { + echo "yay!"; + } + + /** + * Correct param types. + * + * @param string $one + * @param mixed ...$params one or more params + */ + public function correct_param_types5(string $one, ...$params): void { + echo "yay!"; + } + + /** + * Correct return type. + * + * @return string + */ + public function correct_return_type(): string { + return "yay!"; + } + + /** + * Namespaced types. + * + * @param \stdClass $data + * @param \core\user $user + * @return \core\user + */ + public function namespaced_parameter_type( + \stdClass $data, + \core\user $user + ): \core\user { + return $user; + } + + /** + * Namespaced types. + * + * @param null|\stdClass $data + * @param null|\core\test\something|\core\some\other_thing $moredata + * @return \stdClass + */ + public function builtin( + ?\stdClass $data, + \core\test\something|\core\some\other_thing|null $moredata + ): \stdClass { + return new stdClass(); + } +} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_wrong.php new file mode 100644 index 0000000..68b9adb --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_wrong.php @@ -0,0 +1,133 @@ +. + +/** + * A fixture to verify various phpdoc tags in a general location. + * + * @package local_moodlecheck + * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +/** + * A fixture to verify various phpdoc tags in a general location. + * + * @package local_moodlecheck + * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class fixturing_general { + + /** + * Incomplete param annotation (type is missing). + * + * @param $one + * @param $two + */ + public function incomplete_param_annotation($one, $two) { + echo "yoy!"; + } + + /** + * Missing param definition. + * + * @param string $one + * @param bool $two + */ + public function missing_param_defintion() { + echo "yoy!"; + } + + /** + * Missing param annotation. + */ + public function missing_param_annotation($one, $two) { + echo "yoy!"; + } + + /** + * Incomplete param definition. + * + * @param string $one + * @param bool $two + */ + public function incomplete_param_definition(string $one) { + echo "yoy!"; + } + + /** + * Incomplete param annotation (annotation is missing). + * + * @param string $one + */ + public function incomplete_param_annotation1(string $one, $two) { + echo "yoy!"; + } + + /** + * Mismatch param types. + * + * @param string $one + * @param bool $two + */ + public function mismatch_param_types(string $one, array $two = []) { + echo "yoy!"; + } + + /** + * Mismatch param types. + * + * @param string|bool $one + * @param bool $two + */ + public function mismatch_param_types1(string $one, bool $two) { + echo "yoy!"; + } + + /** + * Mismatch param types. + * + * @param string|bool $one + * @param bool $params + */ + public function mismatch_param_types2(string $one, ...$params) { + echo "yoy!"; + } + + /** + * Mismatch param types. + * + * @param string $one + * @param int[] $params + */ + public function mismatch_param_types3(string $one, int $params) { + echo "yoy!"; + } + + /** + * Incomplete return annotation (type is missing). + * + * @return + */ + public function incomplete_return_annotation() { + echo "yoy!"; + } + +} diff --git a/moodle/Tests/Util/PHPDocTypeParserTest.php b/moodle/Tests/Util/PHPDocTypeParserTest.php new file mode 100644 index 0000000..6cfaee5 --- /dev/null +++ b/moodle/Tests/Util/PHPDocTypeParserTest.php @@ -0,0 +1,102 @@ +. + +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 all types right' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_all_types_right', + 'errors' => [], + 'warnings' => [], + ], + 'PHPDocTypes parse wrong' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_parse_wrong', + 'errors' => [ + 45 => 'PHPDoc function parameter 1 name missing or malformed', + 52 => 'PHPDoc function parameter 1 name missing or malformed', + 57 => 'PHPDoc var type missing or malformed', + 60 => 'PHPDoc var type missing or malformed', + 64 => 'PHPDoc var type missing or malformed', + 68 => 'PHPDoc var type missing or malformed', + 72 => 'PHPDoc var type missing or malformed', + 75 => 'PHPDoc var type missing or malformed', + 78 => 'PHPDoc var type missing or malformed', + 81 => 'PHPDoc var type missing or malformed', + 84 => 'PHPDoc var type missing or malformed', + 87 => 'PHPDoc var type missing or malformed', + 90 => 'PHPDoc var type missing or malformed', + 94 => 'PHPDoc var type missing or malformed', + 97 => 'PHPDoc var type missing or malformed', + 100 => 'PHPDoc var type missing or malformed', + 103 => 'PHPDoc var type missing or malformed', + 106 => 'PHPDoc var type missing or malformed', + 109 => 'PHPDoc var type missing or malformed', + 112 => 'PHPDoc var type missing or malformed', + 115 => 'PHPDoc var type missing or malformed', + 121 => 'PHPDoc function parameter 1 type missing or malformed', + 126 => 'PHPDoc var type missing or malformed', + 129 => 'PHPDoc var type missing or malformed', + ], + 'warnings' => [], + ], + ]; + } +} diff --git a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php new file mode 100644 index 0000000..ed5c7a4 --- /dev/null +++ b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php @@ -0,0 +1,432 @@ +. + +/** + * 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 (or CC BY-SA v4 or later) + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * 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 (or CC BY-SA v4 or later) + */ +class types_valid extends types_valid_parent { + + /** @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 + */ + public function parameter_modifiers( + object &$reference, + int ...$splat): void { + } + + /** + * Boolean types + * @param bool|boolean $bool + * @param true|false $literal + */ + 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 + */ + 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 + */ + public function integer_types_complex(int $literal2, int $intmask2, int $intmask3): void { + } + + /** + * Float types + * @param float|double $float + * @param 1.0|-1.0 $literal + */ + 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 + */ + public function string_types(string $string, string $classstring1, string $other, string $literal): void { + } + + /** + * String types complex + * @param class-string $classstring2 + * @param '\'' $stringwithescape + */ + public function string_types_complex(string $classstring2, 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 + */ + public function array_types(array $genarray1, array $genarray2, array $list, + array $shapes1, array $shapes2): void { + } + + /** + * Array types complex + * @param array|array<1|2, string>|array $genarray3 + */ + 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 + */ + 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 + */ + 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(); + } + + /** + * Void type + * @param null $standalonenull + * @param ?int $explicitnullable + * @param ?int $implicitnullable + * @return void + */ + public function void_type( + $standalonenull, + ?int $explicitnullable, + int $implicitnullable=null + ): void { + } + + /** + * User-defined type + * @param types_valid|\types_valid $class + */ + 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(float...): (int|null) $callable3 + * @param \Closure|\Closure(int, int): string $closure + * @param callable-string $callablestring + */ + 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 + */ + 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 + */ + public function iterable_types_complex(iterable $iterable2, iterable $traversable2): void { + } + + /** + * Key and value of + * @param key-of $keyof1 + * @param value-of $valueof1 + */ + public function key_and_value_of(int $keyof1, string $valueof1): void { + } + + /** + * Key and value of complex + * @param key-of> $keyof2 + * @param value-of> $valueof2 + */ + 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 + */ + 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 + + */ + 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 + */ + 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 + */ + public function inheritance( + types_valid_parent $basic, + parent $relative1, + parent $relative2 + ): 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 + */ + 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 + */ + 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..d7c10b0 --- /dev/null +++ b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php @@ -0,0 +1,131 @@ +. + +/** + * 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 (or CC BY-SA v4 or later) + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * 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 (or CC BY-SA v4 or later) + */ +class types_invalid { + + /** + * Expecting variable name, saw end + * @param int + */ + public function expecting_var_saw_end(int $x): void { + } + + /** + * Expecting variable name, saw other (passes Psalm) + * @param int int + */ + 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 array-key&(int|string) 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 + */ + 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/Util/PHPDocTypeParser.php b/moodle/Util/PHPDocTypeParser.php new file mode 100644 index 0000000..5e669d0 --- /dev/null +++ b/moodle/Util/PHPDocTypeParser.php @@ -0,0 +1,1053 @@ +. + +/** + * 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 (or CC BY-SA v4 or later) + */ + +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 bool when we encounter an unknown type, should we go wide or narrow */ + protected bool $gowide = 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 var 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, var: ?non-empty-string, rem: string} + * the simplified type, pass by reference & splat, variable name, and remaining text + */ + public function parseTypeAndVar(?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->gowide = $gowide; + $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 == '...') { + // Add to variable name for code smell check. + $passsplat .= $this->parseToken('...'); + } + } + + // Try to parse variable 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}\"."); + } + $variable = $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."); + } + 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(); + $variable = null; + } + } else { + $variable = null; + } + + return (object)['type' => $type, 'passsplat' => $passsplat, 'var' => $variable, + 'rem' => trim(substr($text, $this->nexts[0]->startpos))]; + } + + /** + * 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, var: ?non-empty-string, rem: string} + * the simplified type, template name, and remaining text + */ + 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->gowide = 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}\"."); + } + $variable = $this->parseToken(); + if ( + !($this->next == null || $this->next == 'of' + || 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(); + $variable = null; + } + + if ($this->next == 'of') { + $this->parseToken('of'); + // 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, 'var' => $variable, 'rem' => trim(substr($text, $this->nexts[0]->startpos))]; + } + + /** + * 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; + } + 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 == "\\") { + // Identifier token. + $endpos = $startpos; + do { + $endpos = $endpos + 1; + $nextchar = ($endpos < strlen($this->text)) ? $this->text[$endpos] : null; + } while ( + $nextchar != null && (ctype_alnum($nextchar) || $nextchar == '_' + || $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; + } + + /** + * 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->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->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."); + } + $nextchar = $next[0]; + + if (in_array(strtolower($next), ['bool', 'boolean', 'true', 'false'])) { + // Bool. + $this->parseToken(); + $type = 'bool'; + } elseif ( + in_array(strtolower($next), ['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. + $inttype = strtolower($this->parseToken()); + if ($inttype == 'int' && $this->next == '<') { + // Integer range. + $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(strtolower($next), ['float', 'double']) + || (ctype_digit($nextchar) || $nextchar == '-') && strpos($next, '.') !== false + ) { + // Float. + $this->parseToken(); + $type = 'float'; + } elseif ( + in_array(strtolower($next), ['string', 'class-string', 'numeric-string', 'literal-string', + 'non-empty-string', 'non-falsy-string', 'truthy-string', ]) + || $nextchar == '"' || $nextchar == "'" + ) { + // String. + $strtype = strtolower($this->parseToken()); + if ($strtype == 'class-string' && $this->next == '<') { + $this->parseToken('<'); + $stringtype = $this->parseAnyType(); + if (!$this->compareTypes('object', $stringtype)) { + throw new \Exception("Error parsing type, class-string type isn't class."); + } + $this->parseToken('>'); + } + $type = 'string'; + } elseif (strtolower($next) == 'callable-string') { + // Callable-string. + $this->parseToken('callable-string'); + $type = 'callable-string'; + } elseif (in_array(strtolower($next), ['array', 'non-empty-array', 'list', 'non-empty-list'])) { + // Array. + $arraytype = strtolower($this->parseToken()); + if ($this->next == '<') { + // Typed array. + $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. + 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 (strtolower($next) == 'object') { + // Object. + $this->parseToken('object'); + if ($this->next == '{') { + // Object shape. + $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 (strtolower($next) == 'resource') { + // Resource. + $this->parseToken('resource'); + $type = 'resource'; + } elseif (in_array(strtolower($next), ['never', 'never-return', 'never-returns', 'no-return'])) { + // Never. + $this->parseToken(); + $type = 'never'; + } elseif (strtolower($next) == 'null') { + // Null. + $this->parseToken('null'); + $type = 'null'; + } elseif (strtolower($next) == 'void') { + // Void. + $this->parseToken('void'); + $type = 'void'; + } elseif (strtolower($next) == 'self') { + // Self. + $this->parseToken('self'); + $type = $this->scope->classname ? $this->scope->classname : 'self'; + } elseif (strtolower($next) == 'parent') { + // Parent. + $this->parseToken('parent'); + $type = $this->scope->parentname ? $this->scope->parentname : 'parent'; + } elseif (in_array(strtolower($next), ['static', '$this'])) { + // Static. + $this->parseToken(); + $type = $this->scope->classname ? "static({$this->scope->classname})" : 'static'; + } elseif ( + strtolower($next) == 'callable' + || $next == "\\Closure" || $next == 'Closure' && $this->scope->namespace == '' + ) { + // Callable. + $callabletype = $this->parseToken(); + if ($this->next == '(') { + $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 (strtolower($next) == 'mixed') { + // Mixed. + $this->parseToken('mixed'); + $type = 'mixed'; + } elseif (strtolower($next) == 'iterable') { + // Iterable (Traversable|array). + $this->parseToken('iterable'); + if ($this->next == '<') { + $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 (strtolower($next) == 'array-key') { + // Array-key (int|string). + $this->parseToken('array-key'); + $type = 'array-key'; + } elseif (strtolower($next) == 'scalar') { + // Scalar can be (bool|int|float|string). + $this->parseToken('scalar'); + $type = 'scalar'; + } elseif (strtolower($next) == 'key-of') { + // Key-of. + $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 (strtolower($next) == 'value-of') { + // Value-of. + $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->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->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; + } +} From 876a9c93098eccc4c707aafba00182edbd7f8d48 Mon Sep 17 00:00:00 2001 From: James C <5689414+james-cnz@users.noreply.github.com> Date: Thu, 21 Mar 2024 15:17:32 +1300 Subject: [PATCH 2/4] Refactored, autofixing, token lists, properties, attributes, updates for Moodle master --- moodle/Sniffs/Commenting/PHPDocTypesSniff.php | 746 ++++++++++++------ .../Commenting/PHPDocTypesSniffTest.php | 42 +- .../fixtures/phpdoctypes_properties_right.php | 45 -- .../fixtures/phpdoctypes_properties_wrong.php | 35 - moodle/Tests/Util/PHPDocTypeParserTest.php | 53 +- moodle/Util/PHPDocTypeParser.php | 120 ++- 6 files changed, 641 insertions(+), 400 deletions(-) delete mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_right.php delete mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_wrong.php diff --git a/moodle/Sniffs/Commenting/PHPDocTypesSniff.php b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php index 630df1f..df6e377 100644 --- a/moodle/Sniffs/Commenting/PHPDocTypesSniff.php +++ b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php @@ -23,10 +23,13 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later (or CC BY-SA v4 or later) */ +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; /** @@ -37,7 +40,8 @@ class PHPDocTypesSniff implements Sniff /** @var ?File the current file */ protected ?File $file = null; - /** @var array{'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int}[] + /** @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 = []; @@ -45,7 +49,7 @@ class PHPDocTypesSniff implements Sniff * classish things: classes, interfaces, traits, and enums */ protected array $artifacts = []; - /** @var ?PHPDocTypeParser */ + /** @var ?PHPDocTypeParser for parsing and comparing types */ protected ?PHPDocTypeParser $typeparser = null; /** @var 1|2 pass 1 for gathering artifact/classish info, 2 for checking */ @@ -54,25 +58,24 @@ class PHPDocTypesSniff implements Sniff /** @var int current token pointer in the file */ protected int $fileptr = 0; - /** @var non-empty-array<\stdClass&object{type: string, namespace: string, uses: string[], templates: string[], - * classname: ?string, parentname: ?string, opened: bool, closer: ?int}> - * file scope: classish, function, etc. We only need a closer if we might be in a switch statement. */ - protected array $scopes; - - /** @var ?(\stdClass&object{tags: array}) PHPDoc comment for upcoming declaration */ + /** @var ?(\stdClass&object{ptr: int, tags: array}) + * PHPDoc comment for upcoming declaration */ protected ?object $commentpending = null; /** @var int how long until we dispose of a pending comment */ protected int $commentpendingcounter = 0; - /** @var ?(\stdClass&object{tags: array}) PHPDoc comment for current declaration */ + /** @var ?(\stdClass&object{ptr: int, tags: array}) + * PHPDoc comment for current declaration */ protected ?object $comment = null; - /** @var array{'code': ?array-key, 'content': string, 'scope_opener'?: int, 'scope_closer'?: int} + /** @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} + /** @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' => '']; @@ -92,17 +95,19 @@ public function register(): array { */ public function process(File $phpcsfile, $stackptr): void { - // Check we haven't already done this file. - if ($phpcsfile == $this->file) { - return; - } - try { $this->file = $phpcsfile; $this->tokens = $phpcsfile->getTokens(); - $this->artifacts = []; + + // Check we haven't already seen this file. + for ($tagcounter = $stackptr - 1; $tagcounter >= 0; $tagcounter--) { + if ($this->tokens[$tagcounter]['code'] == T_OPEN_TAG) { + return; + } + } // Gather atifact info. + $this->artifacts = []; $this->pass = 1; $this->typeparser = null; $this->fileptr = $stackptr; @@ -114,6 +119,7 @@ public function process(File $phpcsfile, $stackptr): void { $this->fileptr = $stackptr; $this->processPass(); } catch (\Exception $e) { + // Give up. The user will probably want to fix parse errors before anything else. $this->file->addError( 'The PHPDoc type sniff failed to parse the file. PHPDoc type checks were not performed.', $this->fileptr < count($this->tokens) ? $this->fileptr : $this->fileptr - 1, @@ -128,127 +134,90 @@ public function process(File $phpcsfile, $stackptr): void { * @phpstan-impure */ protected function processPass(): void { - $this->scopes = [(object)['type' => 'root', 'namespace' => '', 'uses' => [], 'templates' => [], - 'classname' => null, 'parentname' => null, 'opened' => true, 'closer' => null]]; + $scope = (object)[ + 'namespace' => '', 'uses' => [], 'templates' => [], 'closer' => null, + 'classname' => null, 'parentname' => null, 'type' => 'root', + ]; $this->tokenprevious = ['code' => null, 'content' => '']; $this->fetchToken(); $this->commentpending = null; $this->comment = null; - while ($this->token['code']) { + $this->processBlock($scope); + } + + /** + * Process the content of a file, class, or function + * @param \stdClass&object{namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope + * @return void + * @phpstan-impure + */ + protected function processBlock(object $scope): void { + + // Check we are at the start of a scope. + if (!($this->token['code'] == T_OPEN_TAG || $this->token['scope_opener'] == $this->fileptr)) { + throw new \Exception(); + } + + $scope->closer = ($this->token['code'] == T_OPEN_TAG) ? + count($this->tokens) + : $this->token['scope_closer']; + $this->advance(); + + while (true) { // Skip irrelevant tokens. while ( !in_array( $this->token['code'], - [T_NAMESPACE, T_USE, - T_ABSTRACT, T_PUBLIC, T_PROTECTED, T_PRIVATE, T_STATIC, T_READONLY, T_FINAL, - T_CLASS, T_ANON_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, - T_FUNCTION, T_CLOSURE, T_VAR, T_CONST, - T_SEMICOLON, null] + array_merge( + [T_NAMESPACE, T_USE], + Tokens::$methodPrefixes, + [T_READONLY], + Tokens::$ooScopeTokens, + [T_FUNCTION, T_CLOSURE, T_FN, + T_VAR, T_CONST, + null] + ) ) - && (!isset($this->token['scope_opener']) || $this->token['scope_opener'] != $this->fileptr) - && (!isset($this->token['scope_closer']) || $this->token['scope_closer'] != $this->fileptr) + && !($this->fileptr >= $scope->closer) ) { $this->advance(); } - // Check for the end of the file. - if (!$this->token['code']) { - break; - } - // Namespace. - if ($this->token['code'] == T_NAMESPACE && end($this->scopes)->opened) { - $this->processNamespace(); - continue; - } - - // Use. - if ($this->token['code'] == T_USE) { - if (end($this->scopes)->type == 'classish' && end($this->scopes)->opened) { + 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(); - } elseif (end($this->scopes)->type == 'function' && !end($this->scopes)->opened) { - $this->advance(T_USE); - } else { - $this->processUse(); - } - continue; - } - - // Ignore constructor property promotion. This has already been checked. - if ( - end($this->scopes)->type == 'function' && !end($this->scopes)->opened - && in_array($this->token['code'], [T_PUBLIC, T_PROTECTED, T_PRIVATE]) - ) { - $this->advance(); - continue; - } - - // Malformed prior declaration. - if ( - !end($this->scopes)->opened - && !(isset($this->token['scope_opener']) && $this->token['scope_opener'] == $this->fileptr - || $this->token['code'] == T_SEMICOLON) - ) { - throw new \Exception(); - } - - // Opening a scope. - if (isset($this->token['scope_opener']) && $this->token['scope_opener'] == $this->fileptr) { - if ($this->token['scope_closer'] == end($this->scopes)->closer) { - // We're closing the previous scope at the same time. This happens in switch statements. - if (count($this->scopes) <= 1) { - // Trying to close a scope that wasn't open. - throw new \Exception(); - } - array_pop($this->scopes); - } - if (!end($this->scopes)->opened) { - end($this->scopes)->opened = true; } else { - $oldscope = end($this->scopes); - array_push($this->scopes, $newscope = clone $oldscope); - $newscope->type = 'other'; - $newscope->opened = true; - $newscope->closer = $this->tokens[$this->fileptr]['scope_closer']; - } - $this->advance(); - continue; - } - - // Closing a scope (without opening a new one). - if (isset($this->token['scope_closer']) && $this->token['scope_closer'] == $this->fileptr) { - if (count($this->scopes) <= 1) { - // Trying to close a scope that wasn't open. throw new \Exception(); } - array_pop($this->scopes); - $this->advance(); - continue; - } - - // Empty declarations and other semicolons. - if ($this->token['code'] == T_SEMICOLON) { - if (!end($this->scopes)->opened) { - array_pop($this->scopes); - } - $this->advance(T_SEMICOLON); - continue; - } - - // Declarations. - if ( + } elseif ( in_array( $this->token['code'], - [T_ABSTRACT, T_PUBLIC, T_PROTECTED, T_PRIVATE, T_STATIC, T_READONLY, T_FINAL, - T_CLASS, T_ANON_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, - T_FUNCTION, T_CLOSURE, - T_CONST, T_VAR, ] + array_merge( + Tokens::$methodPrefixes, + [T_READONLY], + Tokens::$ooScopeTokens, + [T_FUNCTION, T_CLOSURE, T_FN, + T_CONST, T_VAR, ] + ) ) ) { + // Declarations. // Fetch comment. $this->comment = $this->commentpending; $this->commentpending = null; + // Ignore preceding stuff, and gather info to check this is actually a declaration. $static = false; $staticprecededbynew = ($this->tokenprevious['code'] == T_NEW); while ( @@ -260,30 +229,34 @@ protected function processPass(): void { $static = ($this->token['code'] == T_STATIC); $this->advance(); } + // What kind of declaration is this? if ($static && ($this->token['code'] == T_DOUBLE_COLON || $staticprecededbynew)) { - // Ignore static late binding. - } elseif (in_array($this->token['code'], [T_CLASS, T_ANON_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) { + // It's not a declaration, it's a static late binding. Ignore. + } elseif (in_array($this->token['code'], Tokens::$ooScopeTokens)) { // Classish thing. - $this->processClassish(); - } elseif ($this->token['code'] == T_FUNCTION || $this->token['code'] == T_CLOSURE) { + $this->processClassish($scope); + } elseif (in_array($this->token['code'], [T_FUNCTION, T_CLOSURE, T_FN])) { // Function. - $this->processFunction(); + $this->processFunction($scope); } else { // Variable. - $this->processVariable(); + $this->processVariable($scope); } $this->comment = null; - continue; + } else { + // We got something unrecognised. + throw new \Exception(); } - - // We got something unrecognised. - throw new \Exception(); } - // Some scopes weren't closed. - if (count($this->scopes) != 1) { + // Check we are at the end of the scope. + if ($this->fileptr != $scope->closer) { throw new \Exception(); } + // We can't consume this token. Arrow functions close on the token following their body. + /*if ($this->token['code']) { + $this->advance(); + }*/ } /** @@ -315,7 +288,10 @@ protected function advance($expectedcode = null): void { // Skip stuff that doesn't effect us. while ( $nextptr < count($this->tokens) - && in_array($this->tokens[$nextptr]['code'], [T_WHITESPACE, T_COMMENT, T_INLINE_HTML, T_PHPCS_IGNORE]) + && in_array( + $this->tokens[$nextptr]['code'], + array_merge([T_WHITESPACE, T_COMMENT], Tokens::$phpcsCommentTokens) + ) ) { $nextptr++; } @@ -331,6 +307,18 @@ protected function advance($expectedcode = null): void { $nextptr = $this->fileptr; } + // Allow attributes between the comment and what it relates to. + while ( + $nextptr < count($this->tokens) + && in_array($this->tokens[$nextptr]['code'], [T_WHITESPACE, T_ATTRIBUTE]) + ) { + if ($this->tokens[$nextptr]['code'] == T_ATTRIBUTE) { + $nextptr = $this->tokens[$nextptr]['attribute_closer'] + 1; + } else { + $nextptr++; + } + } + $this->fileptr = $nextptr; $this->fetchToken(); @@ -343,6 +331,19 @@ protected function advance($expectedcode = null): void { } } + /** + * Advance the token pointer to a specific point. + * @param int $newptr + * @return void + * @phpstan-impure + */ + protected function advanceTo(int $newptr): void { + $this->fileptr = $newptr; + $this->commentpending = null; + $this->commentpendingcounter = 0; + $this->fetchToken(); + } + /** * Advance the token pointer when reading PHPDoc comments. * @param array-key $expectedcode What we expect, or null if anything's OK @@ -356,8 +357,7 @@ protected function advanceComment($expectedcode = null): void { !in_array( $this->token['code'], [T_DOC_COMMENT_OPEN_TAG, T_DOC_COMMENT_CLOSE_TAG, T_DOC_COMMENT_STAR, - T_DOC_COMMENT_TAG, T_DOC_COMMENT_STRING, T_DOC_COMMENT_WHITESPACE, - T_PHPCS_IGNORE] + T_DOC_COMMENT_TAG, T_DOC_COMMENT_STRING, T_DOC_COMMENT_WHITESPACE] ) ) { throw new \Exception(); @@ -389,7 +389,7 @@ protected function advanceComment($expectedcode = null): void { * @phpstan-impure */ protected function processComment(): void { - $this->commentpending = (object)['tags' => []]; + $this->commentpending = (object)['ptr' => $this->fileptr, 'tags' => []]; // Skip line starting stuff. while ( @@ -402,22 +402,33 @@ protected function processComment(): void { // For each tag. while ($this->token['code'] != T_DOC_COMMENT_CLOSE_TAG) { + $tag = (object)['ptr' => $this->fileptr, 'content' => '', 'cstartptr' => null, 'cendptr' => null]; // Fetch the tag type. if ($this->token['code'] == T_DOC_COMMENT_TAG) { $tagtype = $this->token['content']; $this->advanceComment(T_DOC_COMMENT_TAG); + while ( + $this->token['code'] == T_DOC_COMMENT_WHITESPACE + && !in_array(substr($this->token['content'], -1), ["\n", "\r"]) + ) { + $this->advanceComment(T_DOC_COMMENT_WHITESPACE); + } } else { $tagtype = ''; } - $tagcontent = ''; // For each line, until we reach a new tag. + // Note: the logic for fixing a comment tag must exactly match this. do { $newline = false; // Fetch line content. while ($this->token['code'] != T_DOC_COMMENT_CLOSE_TAG && !$newline) { - $tagcontent .= $this->token['content']; + 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->advanceComment(); } // Skip next line starting stuff. @@ -434,18 +445,80 @@ protected function processComment(): void { if (!isset($this->commentpending->tags[$tagtype])) { $this->commentpending->tags[$tagtype] = []; } - $this->commentpending->tags[$tagtype][] = trim($tagcontent); + $this->commentpending->tags[$tagtype][] = $tag; } $this->advanceComment(T_DOC_COMMENT_CLOSE_TAG); } + /** + * 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; + $donereplacement = false; + $ptr = $tag->cstartptr; + + $this->file->fixer->beginChangeset(); + + // For each line, until we reach a new tag. + // Note: the logic for this must exactly match that for processing a comment tag. + do { + $newline = false; + // Change line content. + while ($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] === "") { + throw new \Exception(); + } + $this->file->fixer->replaceToken($ptr, $replacementarray[$replacementcounter]); + $donereplacement = true; + } else { + if (!($donereplacement || $replacementarray[$replacementcounter] === "")) { + throw new \Exception(); + } + $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'], [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] === "")) + ) { + throw new \Exception(); + } + + $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(): void { + protected function processNamespace(object $scope): void { $this->advance(T_NAMESPACE); + + // Fetch the namespace. $namespace = ''; while ( in_array( @@ -456,37 +529,46 @@ protected function processNamespace(): void { $namespace .= $this->token['content']; $this->advance(); } + + // Check it's right. if ($namespace != '' && $namespace[strlen($namespace) - 1] == "\\") { throw new \Exception(); } + + // 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(); } - if ($this->token['code'] == T_SEMICOLON) { - end($this->scopes)->namespace = $namespace; + if ($this->token['code'] == T_OPEN_CURLY_BRACKET) { + $scope = clone($scope); + $scope->type = 'namespace'; + $scope->namespace = $namespace; + $this->processBlock($scope); } else { - $oldscope = end($this->scopes); - array_push($this->scopes, $newscope = clone $oldscope); - $newscope->type = 'namespace'; - $newscope->namespace = $namespace; - $newscope->opened = false; - $newscope->closer = null; + $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(): void { + protected function processUse(object $scope): void { $this->advance(T_USE); + + // Loop until we've fetched all imports. $more = false; do { - $namespace = ''; + // Get the type. $type = 'class'; if ($this->token['code'] == T_FUNCTION) { $type = 'function'; @@ -495,6 +577,9 @@ protected function processUse(): void { $type = 'const'; $this->advance(T_CONST); } + + // Get what's being imported + $namespace = ''; while ( in_array( $this->token['code'], @@ -504,18 +589,24 @@ protected function processUse(): void { $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(); } $typestart = $type; + + // Fetch everything in the group. $this->advance(T_OPEN_USE_GROUP); do { - $namespaceend = ''; + // Get the type. $type = $typestart; if ($this->token['code'] == T_FUNCTION) { $type = 'function'; @@ -524,6 +615,9 @@ protected function processUse(): void { $type = 'const'; $this->advance(T_CONST); } + + // Get what's being imported. + $namespaceend = ''; while ( in_array( $this->token['code'], @@ -534,12 +628,17 @@ protected function processUse(): void { $this->advance(); } $namespace = $namespacestart . $namespaceend; + + // Figure out the alias. $alias = substr($namespace, strrpos($namespace, "\\") + 1); $asalias = $this->processUseAsAlias(); $alias = $asalias ?? $alias; + + // Store it. if ($this->pass == 2 && $type == 'class') { - end($this->scopes)->uses[$alias] = $namespace; + $scope->uses[$alias] = $namespace; } + $more = ($this->token['code'] == T_COMMA); if ($more) { $this->advance(T_COMMA); @@ -547,6 +646,8 @@ protected function processUse(): void { } while ($more); $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; @@ -555,8 +656,10 @@ protected function processUse(): void { } $asalias = $this->processUseAsAlias(); $alias = $asalias ?? $alias; + + // Store it. if ($this->pass == 2 && $type == 'class') { - end($this->scopes)->uses[$alias] = $namespace; + $scope->uses[$alias] = $namespace; } } $more = ($this->token['code'] == T_COMMA); @@ -564,9 +667,8 @@ protected function processUse(): void { $this->advance(T_COMMA); } } while ($more); - if ($this->token['code'] != T_SEMICOLON) { - throw new \Exception(); - } + + $this->advance(T_SEMICOLON); } /** @@ -588,17 +690,24 @@ protected function processUseAsAlias(): ?string { /** * Process a classish thing. + * @param \stdClass&object{namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope * @return void * @phpstan-impure */ - protected function processClassish(): void { + protected function processClassish(object $scope): void { + + // New scope. + $scope = clone($scope); + $scope->type = 'classish'; + $scope->closer = null; // Get details. $name = $this->file->getDeclarationName($this->fileptr); - $name = $name ? end($this->scopes)->namespace . "\\" . $name : null; + $name = $name ? $scope->namespace . "\\" . $name : null; $parent = $this->file->findExtendedClassName($this->fileptr); if ($parent && $parent[0] != "\\") { - $parent = end($this->scopes)->namespace . "\\" . $parent; + $parent = $scope->namespace . "\\" . $parent; } $interfaces = $this->file->findImplementedInterfaceNames($this->fileptr); if (!is_array($interfaces)) { @@ -606,18 +715,11 @@ protected function processClassish(): void { } foreach ($interfaces as $index => $interface) { if ($interface && $interface[0] != "\\") { - $interfaces[$index] = end($this->scopes)->namespace . "\\" . $interface; + $interfaces[$index] = $scope->namespace . "\\" . $interface; } } - - // Add to scopes. - $oldscope = end($this->scopes); - array_push($this->scopes, $newscope = clone $oldscope); - $newscope->type = 'classish'; - $newscope->classname = $name; - $newscope->parentname = $parent; - $newscope->opened = false; - $newscope->closer = null; + $scope->classname = $name; + $scope->parentname = $parent; if ($this->pass == 1 && $name) { // Store details. @@ -625,15 +727,77 @@ protected function processClassish(): void { } elseif ($this->pass == 2) { // Check and store templates. if ($this->comment && isset($this->comment->tags['@template'])) { - $this->processTemplates(); + $this->processTemplates($scope); + } + // Check properties. + if ($this->comment) { + // Check each property type. + foreach (['@property', '@property-read', '@property-write'] as $tagname) { + if (!isset($this->comment->tags[$tagname])) { + $this->comment->tags[$tagname] = []; + } + + // Check each individual property. + for ($propnum = 0; $propnum < count($this->comment->tags[$tagname]); $propnum++) { + $docpropdata = $this->typeparser->parseTypeAndVar( + $scope, + $this->comment->tags[$tagname][$propnum]->content, + 1, + false + ); + if (!$docpropdata->type) { + $this->file->addError( + 'PHPDoc class property type missing or malformed', + $this->comment->tags[$tagname][$propnum]->ptr, + 'phpdoc_class_prop_type' + ); + } elseif (!$docpropdata->var) { + $this->file->addError( + 'PHPDoc class property name missing or malformed', + $this->comment->tags[$tagname][$propnum]->ptr, + 'phpdoc_class_prop_name' + ); + } elseif ($docpropdata->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc class property type doesn't conform to recommended style", + $this->comment->tags[$tagname][$propnum]->ptr, + 'phpdoc_class_prop_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $this->comment->tags[$tagname][$propnum], + $docpropdata->fixed + ); + } + } + } + } } } + $parametersptr = isset($this->token['parenthesis_opener']) ? $this->token['parenthesis_opener'] : null; + $blockptr = isset($this->token['scope_opener']) ? $this->token['scope_opener'] : null; + $this->advance(); + + // 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->processParameters($scope); + } + + // Process the content. + if ($blockptr) { + $this->advanceTo($blockptr); + $this->processBlock($scope); + }; } /** - * Process a class trait usage. + * 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 */ @@ -672,37 +836,38 @@ protected function processClassTraitUse(): void { /** * Process a function. + * @param \stdClass&object{namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope * @return void * @phpstan-impure */ - protected function processFunction(): void { + protected function processFunction(object $scope): void { + + // New scope. + $scope = clone($scope); + $scope->type = 'function'; + $scope->closer = null; // Get details. - $name = $this->file->getDeclarationName($this->fileptr); + // Can't fetch name for arrow functions. But we're not doing checks that need the name any more. + // $name = $this->file->getDeclarationName($this->fileptr); $parameters = $this->file->getMethodParameters($this->fileptr); $properties = $this->file->getMethodProperties($this->fileptr); - // Push to scopes. - $oldscope = end($this->scopes); - array_push($this->scopes, $newscope = clone $oldscope); - $newscope->type = 'function'; - $newscope->opened = false; - $newscope->closer = null; - // Checks. if ($this->pass == 2) { // Check for missing docs if not anonymous. - if ($name && !$this->comment) { + /*if ($name && !$this->comment) { $this->file->addWarning( 'PHPDoc function is not documented', $this->fileptr, 'phpdoc_fun_doc_missing' ); - } + }*/ // Check and store templates. if ($this->comment && isset($this->comment->tags['@template'])) { - $this->processTemplates(); + $this->processTemplates($scope); } // Check parameter types. @@ -713,61 +878,80 @@ protected function processFunction(): void { if (count($this->comment->tags['@param']) != count($parameters)) { $this->file->addError( "PHPDoc number of function @param tags doesn't match actual number of parameters", - $this->fileptr, + $this->comment->ptr, 'phpdoc_fun_param_count' ); } + + // Check each individual parameter. for ($varnum = 0; $varnum < count($this->comment->tags['@param']); $varnum++) { $docparamdata = $this->typeparser->parseTypeAndVar( - $newscope, - $this->comment->tags['@param'][$varnum], + $scope, + $this->comment->tags['@param'][$varnum]->content, 2, false ); if (!$docparamdata->type) { $this->file->addError( 'PHPDoc function parameter %s type missing or malformed', - $this->fileptr, + $this->comment->tags['@param'][$varnum]->ptr, 'phpdoc_fun_param_type', [$varnum + 1] ); } elseif (!$docparamdata->var) { $this->file->addError( 'PHPDoc function parameter %s name missing or malformed', - $this->fileptr, + $this->comment->tags['@param'][$varnum]->ptr, 'phpdoc_fun_param_name', [$varnum + 1] ); } elseif ($varnum < count($parameters)) { + // Compare docs against actual parameters. $paramdata = $this->typeparser->parseTypeAndVar( - $newscope, + $scope, $parameters[$varnum]['content'], 3, true ); - if (!$this->typeparser->comparetypes($paramdata->type, $docparamdata->type)) { - $this->file->addError( - 'PHPDoc function parameter %s type mismatch', - $this->fileptr, - 'phpdoc_fun_param_type_mismatch', - [$varnum + 1] - ); - } - if ($paramdata->passsplat != $docparamdata->passsplat) { - $this->file->addWarning( - 'PHPDoc function parameter %s splat mismatch', - $this->fileptr, - 'phpdoc_fun_param_pass_splat_mismatch', - [$varnum + 1] - ); - } if ($paramdata->var != $docparamdata->var) { + // Function parameter names don't match. + // Don't do any more checking, because the parameters might be in the wrong order. $this->file->addError( 'PHPDoc function parameter %s name mismatch', - $this->fileptr, + $this->comment->tags['@param'][$varnum]->ptr, 'phpdoc_fun_param_name_mismatch', [$varnum + 1] ); + } else { + if (!$this->typeparser->comparetypes($paramdata->type, $docparamdata->type)) { + $this->file->addError( + 'PHPDoc function parameter %s type mismatch', + $this->comment->tags['@param'][$varnum]->ptr, + 'phpdoc_fun_param_type_mismatch', + [$varnum + 1] + ); + } elseif ($docparamdata->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc function parameter %s type doesn't conform to recommended style", + $this->comment->tags['@param'][$varnum]->ptr, + 'phpdoc_fun_param_type_style', + [$varnum + 1] + ); + if ($fix) { + $this->fixCommentTag( + $this->comment->tags['@param'][$varnum], + $docparamdata->fixed + ); + } + } + if ($paramdata->passsplat != $docparamdata->passsplat) { + $this->file->addWarning( + 'PHPDoc function parameter %s splat mismatch', + $this->comment->tags['@param'][$varnum]->ptr, + 'phpdoc_fun_param_pass_splat_mismatch', + [$varnum + 1] + ); + } } } } @@ -789,84 +973,156 @@ protected function processFunction(): void { if (count($this->comment->tags['@return']) > 1) { $this->file->addError( 'PHPDoc multiple function @return tags--Put in one tag, seperated by vertical bars |', - $this->fileptr, + $this->comment->tags['@return'][1]->ptr, 'phpdoc_fun_ret_multiple' ); } $retdata = $properties['return_type'] ? $this->typeparser->parseTypeAndVar( - $newscope, + $scope, $properties['return_type'], 0, true ) : (object)['type' => 'mixed']; + + // Check each individual return tag, in case there's more than one. for ($retnum = 0; $retnum < count($this->comment->tags['@return']); $retnum++) { $docretdata = $this->typeparser->parseTypeAndVar( - $newscope, - $this->comment->tags['@return'][$retnum], + $scope, + $this->comment->tags['@return'][$retnum]->content, 0, false ); if (!$docretdata->type) { $this->file->addError( 'PHPDoc function return type missing or malformed', - $this->fileptr, + $this->comment->tags['@return'][$retnum]->ptr, 'phpdoc_fun_ret_type' ); } elseif (!$this->typeparser->comparetypes($retdata->type, $docretdata->type)) { $this->file->addError( 'PHPDoc function return type mismatch', - $this->fileptr, + $this->comment->tags['@return'][$retnum]->ptr, 'phpdoc_fun_ret_type_mismatch' ); + } elseif ($docretdata->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc function return type doesn't conform to recommended style", + $this->comment->tags['@return'][$retnum]->ptr, + 'phpdoc_fun_ret_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $this->comment->tags['@return'][$retnum], + $docretdata->fixed + ); + } } } } } + $parametersptr = isset($this->token['parenthesis_opener']) ? $this->token['parenthesis_opener'] : null; + $blockptr = isset($this->token['scope_opener']) ? $this->token['scope_opener'] : null; + $this->advance(); - if ($this->token['code'] == T_BITWISE_AND) { - $this->advance(T_BITWISE_AND); - } - // Function name. - if ($this->token['code'] == T_STRING) { - $this->advance(T_STRING); + // Parameters could contain anonymous classes or functions. + if ($parametersptr) { + $this->advanceTo($parametersptr); + $this->processParameters($scope); } - // Parameters. - if ($this->token['code'] != T_OPEN_PARENTHESIS) { - throw new \Exception(); + // Content. + if ($blockptr) { + $this->advanceTo($blockptr); + $this->processBlock($scope); + }; + } + + /** + * Search parameter default values for anonymous classes and functions + * @param \stdClass&object{namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope + * @return void + * @phpstan-impure + */ + protected function processParameters(object $scope): void { + + $scope = clone($scope); + $scope->closer = $this->token['parenthesis_closer']; + $this->advance(T_OPEN_PARENTHESIS); + + while (true) { + // Skip irrelevant tokens. + while ( + !in_array($this->token['code'], [T_ANON_CLASS, T_CLOSURE, T_FN]) + && $this->fileptr < $scope->closer + ) { + $this->advance(); + } + + if ($this->fileptr >= $scope->closer) { + // End of the parameters. + break; + } elseif ($this->token['code'] == T_ANON_CLASS) { + // Classish thing. + $this->processClassish($scope); + } elseif (in_array($this->token['code'], [T_CLOSURE, T_FN])) { + // Function. + $this->processFunction($scope); + } else { + // Something unrecognised. + throw new \Exception(); + } } + $this->advance(T_CLOSE_PARENTHESIS); } + /** * Process templates. + * @param \stdClass&object{namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope * @return void * @phpstan-impure */ - protected function processTemplates(): void { - $newscope = end($this->scopes); - foreach ($this->comment->tags['@template'] as $templatetext) { - $templatedata = $this->typeparser->parseTemplate($newscope, $templatetext); + protected function processTemplates(object $scope): void { + foreach ($this->comment->tags['@template'] as $templatetag) { + $templatedata = $this->typeparser->parseTemplate($scope, $templatetag->content); if (!$templatedata->var) { - $this->file->addError('PHPDoc template name missing or malformed', $this->fileptr, 'phpdoc_template_name'); + $this->file->addError('PHPDoc template name missing or malformed', $templatetag->ptr, 'phpdoc_template_name'); } elseif (!$templatedata->type) { - $this->file->addError('PHPDoc template type missing or malformed', $this->fileptr, 'phpdoc_template_type'); - $newscope->templates[$templatedata->var] = 'never'; + $this->file->addError('PHPDoc template type missing or malformed', $templatetag->ptr, 'phpdoc_template_type'); + $scope->templates[$templatedata->var] = 'never'; } else { - $newscope->templates[$templatedata->var] = $templatedata->type; + $scope->templates[$templatedata->var] = $templatedata->type; + if ($templatedata->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc tempate type doesn't conform to recommended style", + $templatetag->ptr, + 'phpdoc_template_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $templatetag, + $templatedata->fixed + ); + } + } } } } /** * Process a variable. + * @param \stdClass&object{namespace: string, uses: string[], templates: string[], + * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope * @return void * @phpstan-impure */ - protected function processVariable(): void { + protected function processVariable($scope): void { // Parse var/const token. $const = ($this->token['code'] == T_CONST); @@ -895,32 +1151,34 @@ protected function processVariable(): void { throw new \Exception(); } - // Checking. + // Type checking. if ($this->pass == 2) { // Get properties, unless it's a function static variable or constant. - $properties = (end($this->scopes)->type == 'classish' && !$const) ? + $properties = ($scope->type == 'classish' && !$const) ? $this->file->getMemberProperties($this->fileptr) : null; - if (!$this->comment && end($this->scopes)->type == 'classish') { + if (!$this->comment && $scope->type == 'classish') { // Require comments for class variables and constants. - $this->file->addWarning( + /*$this->file->addWarning( 'PHPDoc variable or constant is not documented', $this->fileptr, 'phpdoc_var_doc_missing' - ); + );*/ } elseif ($this->comment) { if (!isset($this->comment->tags['@var'])) { $this->comment->tags['@var'] = []; } - if (count($this->comment->tags['@var']) < 1) { - $this->file->addError('PHPDoc missing @var tag', $this->fileptr, 'phpdoc_var_missing'); + // Missing or multiple vars. + /*if (count($this->comment->tags['@var']) < 1) { + $this->file->addError('PHPDoc missing @var tag', $this->comment->ptr, 'phpdoc_var_missing'); } elseif (count($this->comment->tags['@var']) > 1) { - $this->file->addError('PHPDoc multiple @var tags', $this->fileptr, 'phpdoc_var_multiple'); - } + $this->file->addError('PHPDoc multiple @var tags', $this->comment->tags['@var'][1]->ptr, 'phpdoc_var_multiple'); + }*/ + // Var type check and match. $vardata = ($properties && $properties['type']) ? $this->typeparser->parseTypeAndVar( - end($this->scopes), + $scope, $properties['type'], 0, true @@ -928,24 +1186,35 @@ protected function processVariable(): void { : (object)['type' => 'mixed']; for ($varnum = 0; $varnum < count($this->comment->tags['@var']); $varnum++) { $docvardata = $this->typeparser->parseTypeAndVar( - end($this->scopes), - $this->comment->tags['@var'][$varnum], + $scope, + $this->comment->tags['@var'][$varnum]->content, 0, false ); if (!$docvardata->type) { $this->file->addError( 'PHPDoc var type missing or malformed', - $this->fileptr, - 'phpdoc_var_type', - [$varnum + 1] + $this->comment->tags['@var'][$varnum]->ptr, + 'phpdoc_var_type' ); } elseif (!$this->typeparser->comparetypes($vardata->type, $docvardata->type)) { $this->file->addError( 'PHPDoc var type mismatch', - $this->fileptr, + $this->comment->tags['@var'][$varnum]->ptr, 'phpdoc_var_type_mismatch' ); + } elseif ($docvardata->fixed) { + $fix = $this->file->addFixableWarning( + "PHPDoc var type doesn't conform to recommended style", + $this->comment->tags['@var'][$varnum]->ptr, + 'phpdoc_var_type_style' + ); + if ($fix) { + $this->fixCommentTag( + $this->comment->tags['@var'][$varnum], + $docvardata->fixed + ); + } } } } @@ -956,5 +1225,6 @@ protected function processVariable(): void { if (!in_array($this->token['code'], [T_EQUAL, T_COMMA, T_SEMICOLON])) { throw new \Exception(); } + $this->advance(); } } diff --git a/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php index a15991c..8fa40e9 100644 --- a/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php +++ b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php @@ -72,25 +72,6 @@ public static function provider(): array { 'errors' => [], 'warnings' => [], ], - 'PHPDocTypes properties right' => [ - 'fixture' => 'phpdoctypes_properties_right', - 'errors' => [], - 'warnings' => [], - ], - 'PHPDocTypes properties wrong' => [ - 'fixture' => 'phpdoctypes_properties_wrong', - 'errors' => [ - 33 => 'PHPDoc missing @var tag', - ], - 'warnings' => [ - 23 => 'PHPDoc variable or constant is not documented', - 24 => 'PHPDoc variable or constant is not documented', - 25 => 'PHPDoc variable or constant is not documented', - 26 => 'PHPDoc variable or constant is not documented', - 27 => 'PHPDoc variable or constant is not documented', - 28 => 'PHPDoc variable or constant is not documented', - ], - ], 'PHPDocTypes tags general right' => [ 'fixture' => 'phpdoctypes_tags_general_right', 'errors' => [], @@ -99,19 +80,20 @@ public static function provider(): array { 'PHPDocTypes tags general wrong' => [ 'fixture' => 'phpdoctypes_tags_general_wrong', 'errors' => [ - 44 => 2, - 54 => "PHPDoc number of function @param tags doesn't match actual number of parameters", - 61 => "PHPDoc number of function @param tags doesn't match actual number of parameters", - 71 => "PHPDoc number of function @param tags doesn't match actual number of parameters", - 80 => "PHPDoc number of function @param tags doesn't match actual number of parameters", - 90 => 'PHPDoc function parameter 2 type mismatch', - 100 => 'PHPDoc function parameter 1 type mismatch', - 110 => 'PHPDoc function parameter 1 type mismatch', - 120 => 'PHPDoc function parameter 2 type mismatch', - 129 => 'PHPDoc function return type missing or malformed', + 41 => "PHPDoc function parameter 1 type missing or malformed", + 42 => "PHPDoc function parameter 2 type missing or malformed", + 48 => "PHPDoc number of function @param tags doesn't match actual number of parameters", + 58 => "PHPDoc number of function @param tags doesn't match actual number of parameters", + 65 => "PHPDoc number of function @param tags doesn't match actual number of parameters", + 75 => "PHPDoc number of function @param tags doesn't match actual number of parameters", + 88 => 'PHPDoc function parameter 2 type mismatch', + 97 => 'PHPDoc function parameter 1 type mismatch', + 107 => 'PHPDoc function parameter 1 type mismatch', + 118 => 'PHPDoc function parameter 2 type mismatch', + 127 => 'PHPDoc function return type missing or malformed', ], 'warnings' => [ - 110 => 'PHPDoc function parameter 2 splat mismatch', + 108 => 'PHPDoc function parameter 2 splat mismatch', ], ], ]; diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_right.php deleted file mode 100644 index 216ed01..0000000 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_right.php +++ /dev/null @@ -1,45 +0,0 @@ -. - -defined('MOODLE_INTERNAL') || die(); - -/** - * A dummy class for tests of rules involving properties. - */ -class dummy_with_properties { - - /** - * @var mixed $documented1 I'm just a dummy! - */ - var $documented1; - /** - * @var ?string $documented2 I'm just a dummy! - */ - var mixed $documented2; - /** - * @var mixed $documented3 I'm just a dummy! - */ - private $documented3; - /** - * @var ?string $documented4 I'm just a dummy! - */ - private ?string $documented4; - - /** - * @var A correctly documented constant. - */ - const CORRECTLY_DOCUMENTED_CONSTANT = 0; -} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_wrong.php deleted file mode 100644 index 0b14efa..0000000 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_properties_wrong.php +++ /dev/null @@ -1,35 +0,0 @@ -. - -defined('MOODLE_INTERNAL') || die(); - -/** - * A dummy class for tests of rules involving properties. - */ -class dummy_with_properties { - var $undocumented1; - var ?string $undocumented2; - private $undocumented3; - private ?string $undocumented4; - const UNDOCUMENTED_CONSTANT1 = 0; - public const UNDOCUMENTED_CONSTANT2 = 0; - - /** - * @const A wrongly documented constant. - */ - const WRONGLY_DOCUMENTED_CONSTANT = 0; - -} diff --git a/moodle/Tests/Util/PHPDocTypeParserTest.php b/moodle/Tests/Util/PHPDocTypeParserTest.php index 6cfaee5..02a9c91 100644 --- a/moodle/Tests/Util/PHPDocTypeParserTest.php +++ b/moodle/Tests/Util/PHPDocTypeParserTest.php @@ -65,35 +65,38 @@ public static function provider(): array { 'PHPDocTypes all types right' => [ 'fixture' => 'phpdoctypes/phpdoctypes_all_types_right', 'errors' => [], - 'warnings' => [], + 'warnings' => [ + 128 => "PHPDoc function parameter 1 type doesn't conform to recommended style", + 136 => "PHPDoc function parameter 1 type doesn't conform to recommended style", + ], ], 'PHPDocTypes parse wrong' => [ 'fixture' => 'phpdoctypes/phpdoctypes_parse_wrong', 'errors' => [ - 45 => 'PHPDoc function parameter 1 name missing or malformed', - 52 => 'PHPDoc function parameter 1 name missing or malformed', - 57 => 'PHPDoc var type missing or malformed', - 60 => 'PHPDoc var type missing or malformed', - 64 => 'PHPDoc var type missing or malformed', - 68 => 'PHPDoc var type missing or malformed', - 72 => 'PHPDoc var type missing or malformed', - 75 => 'PHPDoc var type missing or malformed', - 78 => 'PHPDoc var type missing or malformed', - 81 => 'PHPDoc var type missing or malformed', - 84 => 'PHPDoc var type missing or malformed', - 87 => 'PHPDoc var type missing or malformed', - 90 => 'PHPDoc var type missing or malformed', - 94 => 'PHPDoc var type missing or malformed', - 97 => 'PHPDoc var type missing or malformed', - 100 => 'PHPDoc var type missing or malformed', - 103 => 'PHPDoc var type missing or malformed', - 106 => 'PHPDoc var type missing or malformed', - 109 => 'PHPDoc var type missing or malformed', - 112 => 'PHPDoc var type missing or malformed', - 115 => 'PHPDoc var type missing or malformed', - 121 => 'PHPDoc function parameter 1 type missing or malformed', - 126 => 'PHPDoc var type missing or malformed', - 129 => 'PHPDoc var type missing or malformed', + 43 => 'PHPDoc function parameter 1 name missing or malformed', + 50 => 'PHPDoc function parameter 1 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 1 type missing or malformed', + 125 => 'PHPDoc var type missing or malformed', + 128 => 'PHPDoc var type missing or malformed', ], 'warnings' => [], ], diff --git a/moodle/Util/PHPDocTypeParser.php b/moodle/Util/PHPDocTypeParser.php index 5e669d0..428b85c 100644 --- a/moodle/Util/PHPDocTypeParser.php +++ b/moodle/Util/PHPDocTypeParser.php @@ -26,6 +26,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later (or CC BY-SA v4 or later) */ +declare(strict_types=1); + namespace MoodleHQ\MoodleCS\moodle\Util; /** @@ -139,6 +141,9 @@ class PHPDocTypeParser /** @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; @@ -162,8 +167,9 @@ public function __construct(?array $artifacts = null) { * @param string $text the text to parse * @param 0|1|2|3 $getwhat what to get 0=type only 1=also var 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, var: ?non-empty-string, rem: string} - * the simplified type, pass by reference & splat, variable name, and remaining text + * @return object{type: ?non-empty-string, passsplat: string, var: ?non-empty-string, + * rem: string, fixed: ?string} + * the simplified type, pass by reference & splat, variable name, remaining text, and fixed text */ public function parseTypeAndVar(?object $scope, string $text, int $getwhat, bool $gowide): object { @@ -174,6 +180,7 @@ public function parseTypeAndVar(?object $scope, string $text, int $getwhat, bool $this->scope = (object)['namespace' => '', 'uses' => [], 'templates' => [], 'classname' => null, 'parentname' => null]; } $this->text = $text; + $this->replacements = []; $this->gowide = $gowide; $this->nexts = []; $this->next = $this->next(); @@ -249,15 +256,16 @@ public function parseTypeAndVar(?object $scope, string $text, int $getwhat, bool } return (object)['type' => $type, 'passsplat' => $passsplat, 'var' => $variable, - 'rem' => trim(substr($text, $this->nexts[0]->startpos))]; + 'rem' => trim(substr($text, $this->nexts[0]->startpos)), + 'fixed' => $type ? $this->getFixed() : null]; } /** * 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, var: ?non-empty-string, rem: string} - * the simplified type, template name, and remaining text + * @return object{type: ?non-empty-string, var: ?non-empty-string, rem: string, fixed: ?string} + * the simplified type, template name, remaining text, and fixed text */ public function parseTemplate(?object $scope, string $text): object { @@ -268,6 +276,7 @@ public function parseTemplate(?object $scope, string $text): object { $this->scope = (object)['namespace' => '', 'uses' => [], 'templates' => [], 'classname' => null, 'parentname' => null]; } $this->text = $text; + $this->replacements = []; $this->gowide = false; $this->nexts = []; $this->next = $this->next(); @@ -316,7 +325,9 @@ public function parseTemplate(?object $scope, string $text): object { $type = 'mixed'; } - return (object)['type' => $type, 'var' => $variable, 'rem' => trim(substr($text, $this->nexts[0]->startpos))]; + return (object)['type' => $type, 'var' => $variable, + 'rem' => trim(substr($text, $this->nexts[0]->startpos)), + 'fixed' => $type ? $this->getFixed() : null]; } /** @@ -554,6 +565,35 @@ protected function parseToken(?string $expect = null): string { 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? @@ -714,19 +754,22 @@ protected function parseBasicType(): string { if ($next == null) { throw new \Exception("Error parsing type, expected type, saw end."); } + $lowernext = strtolower($next); $nextchar = $next[0]; - if (in_array(strtolower($next), ['bool', 'boolean', 'true', 'false'])) { + if (in_array($lowernext, ['bool', 'boolean', 'true', 'false'])) { // Bool. + $this->correctToken(($lowernext == 'boolean') ? 'bool' : $lowernext); $this->parseToken(); $type = 'bool'; } elseif ( - in_array(strtolower($next), ['int', 'integer', 'positive-int', 'negative-int', + 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. + $this->correctToken(($lowernext == 'integer') ? 'int' : $lowernext); $inttype = strtolower($this->parseToken()); if ($inttype == 'int' && $this->next == '<') { // Integer range. @@ -776,18 +819,22 @@ protected function parseBasicType(): string { } $type = 'int'; } elseif ( - in_array(strtolower($next), ['float', 'double']) + in_array($lowernext, ['float', 'double']) || (ctype_digit($nextchar) || $nextchar == '-') && strpos($next, '.') !== false ) { // Float. + $this->correctToken($lowernext); $this->parseToken(); $type = 'float'; } elseif ( - in_array(strtolower($next), ['string', 'class-string', 'numeric-string', 'literal-string', + in_array($lowernext, ['string', 'class-string', 'numeric-string', 'literal-string', 'non-empty-string', 'non-falsy-string', 'truthy-string', ]) || $nextchar == '"' || $nextchar == "'" ) { // String. + if ($nextchar != '"' && $nextchar != "'") { + $this->correctToken($lowernext); + } $strtype = strtolower($this->parseToken()); if ($strtype == 'class-string' && $this->next == '<') { $this->parseToken('<'); @@ -798,12 +845,14 @@ protected function parseBasicType(): string { $this->parseToken('>'); } $type = 'string'; - } elseif (strtolower($next) == 'callable-string') { + } elseif ($lowernext == 'callable-string') { // Callable-string. + $this->correctToken($lowernext); $this->parseToken('callable-string'); $type = 'callable-string'; - } elseif (in_array(strtolower($next), ['array', 'non-empty-array', 'list', 'non-empty-list'])) { + } elseif (in_array($lowernext, ['array', 'non-empty-array', 'list', 'non-empty-list'])) { // Array. + $this->correctToken($lowernext); $arraytype = strtolower($this->parseToken()); if ($this->next == '<') { // Typed array. @@ -853,8 +902,9 @@ protected function parseBasicType(): string { $this->parseToken('}'); } $type = 'array'; - } elseif (strtolower($next) == 'object') { + } elseif ($lowernext == 'object') { // Object. + $this->correctToken($lowernext); $this->parseToken('object'); if ($this->next == '{') { // Object shape. @@ -881,39 +931,49 @@ protected function parseBasicType(): string { $this->parseToken('}'); } $type = 'object'; - } elseif (strtolower($next) == 'resource') { + } elseif ($lowernext == 'resource') { // Resource. + $this->correctToken($lowernext); $this->parseToken('resource'); $type = 'resource'; - } elseif (in_array(strtolower($next), ['never', 'never-return', 'never-returns', 'no-return'])) { + } elseif (in_array($lowernext, ['never', 'never-return', 'never-returns', 'no-return'])) { // Never. + $this->correctToken($lowernext); $this->parseToken(); $type = 'never'; - } elseif (strtolower($next) == 'null') { + } elseif ($lowernext == 'null') { // Null. + $this->correctToken($lowernext); $this->parseToken('null'); $type = 'null'; - } elseif (strtolower($next) == 'void') { + } elseif ($lowernext == 'void') { // Void. + $this->correctToken($lowernext); $this->parseToken('void'); $type = 'void'; - } elseif (strtolower($next) == 'self') { + } elseif ($lowernext == 'self') { // Self. + $this->correctToken($lowernext); $this->parseToken('self'); $type = $this->scope->classname ? $this->scope->classname : 'self'; - } elseif (strtolower($next) == 'parent') { + } elseif ($lowernext == 'parent') { // Parent. + $this->correctToken($lowernext); $this->parseToken('parent'); $type = $this->scope->parentname ? $this->scope->parentname : 'parent'; - } elseif (in_array(strtolower($next), ['static', '$this'])) { + } elseif (in_array($lowernext, ['static', '$this'])) { // Static. + $this->correctToken($lowernext); $this->parseToken(); $type = $this->scope->classname ? "static({$this->scope->classname})" : 'static'; } elseif ( - strtolower($next) == 'callable' + $lowernext == 'callable' || $next == "\\Closure" || $next == 'Closure' && $this->scope->namespace == '' ) { // Callable. + if ($lowernext == 'callable') { + $this->correctToken($lowernext); + } $callabletype = $this->parseToken(); if ($this->next == '(') { $this->parseToken('('); @@ -949,12 +1009,14 @@ protected function parseBasicType(): string { } else { $type = "\\Closure"; } - } elseif (strtolower($next) == 'mixed') { + } elseif ($lowernext == 'mixed') { // Mixed. + $this->correctToken($lowernext); $this->parseToken('mixed'); $type = 'mixed'; - } elseif (strtolower($next) == 'iterable') { + } elseif ($lowernext == 'iterable') { // Iterable (Traversable|array). + $this->correctToken($lowernext); $this->parseToken('iterable'); if ($this->next == '<') { $this->parseToken('<'); @@ -970,16 +1032,19 @@ protected function parseBasicType(): string { $this->parseToken('>'); } $type = 'iterable'; - } elseif (strtolower($next) == 'array-key') { + } elseif ($lowernext == 'array-key') { // Array-key (int|string). + $this->correctToken($lowernext); $this->parseToken('array-key'); $type = 'array-key'; - } elseif (strtolower($next) == 'scalar') { + } elseif ($lowernext == 'scalar') { // Scalar can be (bool|int|float|string). + $this->correctToken($lowernext); $this->parseToken('scalar'); $type = 'scalar'; - } elseif (strtolower($next) == 'key-of') { + } elseif ($lowernext == 'key-of') { // Key-of. + $this->correctToken($lowernext); $this->parseToken('key-of'); $this->parseToken('<'); $iterable = $this->parseAnyType(); @@ -988,8 +1053,9 @@ protected function parseBasicType(): string { } $this->parseToken('>'); $type = $this->gowide ? 'mixed' : 'never'; - } elseif (strtolower($next) == 'value-of') { + } elseif ($lowernext == 'value-of') { // Value-of. + $this->correctToken($lowernext); $this->parseToken('value-of'); $this->parseToken('<'); $iterable = $this->parseAnyType(); From b40437820c5212ec4bd92b26cc1de6c447ad89ea Mon Sep 17 00:00:00 2001 From: James C <5689414+james-cnz@users.noreply.github.com> Date: Sun, 31 Mar 2024 20:08:03 +1300 Subject: [PATCH 3/4] Error recovery, Less parsing, Check for misplaced tags, More test coverage, Fixes, Licence, and Tidying --- moodle/Sniffs/Commenting/PHPDocTypesSniff.php | 1239 ++++++++++------- .../Commenting/PHPDocTypesSniffTest.php | 78 +- .../phpdoctypes_docs_missing_wrong.php | 57 + .../phpdoctypes_general_right.php | 53 +- .../phpdoctypes/phpdoctypes_general_wrong.php | 103 ++ .../phpdoctypes_method_union_types_right.php | 0 .../phpdoctypes_namespace_right.php | 50 + .../phpdoctypes/phpdoctypes_parse_wrong.php | 94 ++ .../phpdoctypes/phpdoctypes_style_wrong.php | 64 + .../phpdoctypes_style_wrong.php.fixed | 64 + .../phpdoctypes_tags_general_right.php | 8 +- .../phpdoctypes_tags_general_wrong.php | 133 -- moodle/Tests/Util/PHPDocTypeParserTest.php | 15 +- .../phpdoctypes_all_types_right.php | 56 +- .../phpdoctypes/phpdoctypes_parse_wrong.php | 11 +- moodle/Util/PHPDocTypeParser.php | 41 +- 16 files changed, 1334 insertions(+), 732 deletions(-) create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_wrong.php rename moodle/Tests/Sniffs/Commenting/fixtures/{ => phpdoctypes}/phpdoctypes_general_right.php (71%) create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_wrong.php rename moodle/Tests/Sniffs/Commenting/fixtures/{ => phpdoctypes}/phpdoctypes_method_union_types_right.php (100%) create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_namespace_right.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php.fixed rename moodle/Tests/Sniffs/Commenting/fixtures/{ => phpdoctypes}/phpdoctypes_tags_general_right.php (96%) delete mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_wrong.php diff --git a/moodle/Sniffs/Commenting/PHPDocTypesSniff.php b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php index df6e377..ca3131c 100644 --- a/moodle/Sniffs/Commenting/PHPDocTypesSniff.php +++ b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php @@ -20,13 +20,16 @@ * * @copyright 2024 Otago Polytechnic * @author James Calder - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later (or CC BY-SA v4 or later) + * @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; +define('DEBUG_MODE', false); +define('CHECK_HAS_DOCS', false); + use PHP_CodeSniffer\Sniffs\Sniff; use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Util\Tokens; @@ -40,9 +43,10 @@ class PHPDocTypesSniff implements Sniff /** @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 */ + /** @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 @@ -58,29 +62,28 @@ class PHPDocTypesSniff implements Sniff /** @var int current token pointer in the file */ protected int $fileptr = 0; - /** @var ?(\stdClass&object{ptr: int, tags: array}) - * PHPDoc comment for upcoming declaration */ + /** @var ?( + * \stdClass&object{ + * ptr: int, + * tags: array + * } + * ) PHPDoc comment for upcoming declaration */ protected ?object $commentpending = null; - /** @var int how long until we dispose of a pending comment */ - protected int $commentpendingcounter = 0; - - /** @var ?(\stdClass&object{ptr: int, tags: array}) - * PHPDoc comment for current declaration */ - protected ?object $comment = 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 */ + /** @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 */ + /** @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 (only process once per file). + * Register for open tag. * @return array-key[] */ public function register(): array { @@ -91,172 +94,223 @@ public function register(): array { * 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 void + * @return int returns pointer to end of file to avoid being called further */ - public function process(File $phpcsfile, $stackptr): void { + public function process(File $phpcsfile, $stackptr): int { try { $this->file = $phpcsfile; $this->tokens = $phpcsfile->getTokens(); - // Check we haven't already seen this file. - for ($tagcounter = $stackptr - 1; $tagcounter >= 0; $tagcounter--) { - if ($this->tokens[$tagcounter]['code'] == T_OPEN_TAG) { - return; - } - } - // Gather atifact info. $this->artifacts = []; $this->pass = 1; $this->typeparser = null; - $this->fileptr = $stackptr; - $this->processPass(); + $this->processPass($stackptr); // Check the PHPDoc types. $this->pass = 2; $this->typeparser = new PHPDocTypeParser($this->artifacts); - $this->fileptr = $stackptr; - $this->processPass(); + $this->processPass($stackptr); } catch (\Exception $e) { - // Give up. The user will probably want to fix parse errors before anything else. + // 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.', + '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(): void { + 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->comment = null; - $this->processBlock($scope); + $this->processBlock($scope, 0/*file*/); } /** - * Process the content of a file, class, or function - * @param \stdClass&object{namespace: string, uses: string[], templates: string[], - * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope + * 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): void { + protected function processBlock(object $scope, int $type): void { - // Check we are at the start of a scope. - if (!($this->token['code'] == T_OPEN_TAG || $this->token['scope_opener'] == $this->fileptr)) { - throw new \Exception(); + // Check we are at the start of a scope, and store scope closer. + if ($type == 0/*file*/) { + if (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']; } - - $scope->closer = ($this->token['code'] == T_OPEN_TAG) ? - count($this->tokens) - : $this->token['scope_closer']; $this->advance(); while (true) { - // Skip irrelevant tokens. - while ( - !in_array( - $this->token['code'], - array_merge( - [T_NAMESPACE, T_USE], - Tokens::$methodPrefixes, - [T_READONLY], - Tokens::$ooScopeTokens, - [T_FUNCTION, T_CLOSURE, T_FN, - T_VAR, T_CONST, - null] + // 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 { - throw new \Exception(); + && ($this->fileptr < $scope->closer) + ) { + $this->advance(); } - } elseif ( - in_array( - $this->token['code'], - array_merge( - Tokens::$methodPrefixes, - [T_READONLY], - Tokens::$ooScopeTokens, - [T_FUNCTION, T_CLOSURE, T_FN, - T_CONST, T_VAR, ] - ) - ) - ) { - // Declarations. - // Fetch comment. - $this->comment = $this->commentpending; - $this->commentpending = null; - // Ignore preceding stuff, and gather info to check this is actually a declaration. - $static = false; - $staticprecededbynew = ($this->tokenprevious['code'] == T_NEW); - while ( + + 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'], - [T_ABSTRACT, T_PUBLIC, T_PROTECTED, T_PRIVATE, T_STATIC, T_READONLY, T_FINAL] + array_merge( + Tokens::$methodPrefixes, + [T_ATTRIBUTE, T_READONLY], + Tokens::$ooScopeTokens, + [T_FUNCTION, T_CLOSURE, T_FN, + T_CONST, T_VAR, ] + ) ) ) { - $static = ($this->token['code'] == T_STATIC); + // 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"); } - // 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. Ignore. - } elseif (in_array($this->token['code'], Tokens::$ooScopeTokens)) { - // Classish thing. - $this->processClassish($scope); - } elseif (in_array($this->token['code'], [T_FUNCTION, T_CLOSURE, T_FN])) { - // Function. - $this->processFunction($scope); - } else { - // Variable. - $this->processVariable($scope); + } catch (\Exception $e) { + // Just give up on whatever we're doing and try again, unless in debug mode. + if (DEBUG_MODE) { + throw $e; } - $this->comment = null; - } else { - // We got something unrecognised. - throw new \Exception(); } } // Check we are at the end of the scope. - if ($this->fileptr != $scope->closer) { - throw new \Exception(); + if (DEBUG_MODE && $this->fileptr != $scope->closer) { + throw new \Exception("Malformed scope closer"); } - // We can't consume this token. Arrow functions close on the token following their body. - /*if ($this->token['code']) { - $this->advance(); - }*/ + // We can't consume the last token. Arrow functions close on the token following their body. } /** @@ -266,8 +320,8 @@ protected function processBlock(object $scope): void { */ protected function fetchToken(): void { $this->token = ($this->fileptr < count($this->tokens)) ? - $this->tokens[$this->fileptr] - : ['code' => null, 'content' => '']; + $this->tokens[$this->fileptr] + : ['code' => null, 'content' => '']; } /** @@ -280,54 +334,43 @@ 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(); + throw new \Exception("Unexpected token, saw: {$this->token['content']}"); } - $nextptr = $this->fileptr + 1; - - // Skip stuff that doesn't effect us. - while ( - $nextptr < count($this->tokens) - && in_array( - $this->tokens[$nextptr]['code'], - array_merge([T_WHITESPACE, T_COMMENT], Tokens::$phpcsCommentTokens) - ) - ) { - $nextptr++; + // Dispose of unused comment, if any. + if ($this->commentpending) { + $this->processPossVarComment(null, $this->commentpending); + $this->commentpending = null; } $this->tokenprevious = $this->token; - // Process PHPDoc comments. - while ($nextptr < count($this->tokens) && $this->tokens[$nextptr]['code'] == T_DOC_COMMENT_OPEN_TAG) { - $this->fileptr = $nextptr; - $this->fetchToken(); - $this->processComment(); - $this->commentpendingcounter = 2; - $nextptr = $this->fileptr; - } + $this->fileptr++; + $this->fetchToken(); - // Allow attributes between the comment and what it relates to. + // Skip stuff that doesn't affect us, process PHPDoc comments. while ( - $nextptr < count($this->tokens) - && in_array($this->tokens[$nextptr]['code'], [T_WHITESPACE, T_ATTRIBUTE]) + $this->fileptr < count($this->tokens) + && in_array($this->tokens[$this->fileptr]['code'], Tokens::$emptyTokens) ) { - if ($this->tokens[$nextptr]['code'] == T_ATTRIBUTE) { - $nextptr = $this->tokens[$nextptr]['attribute_closer'] + 1; + 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 { - $nextptr++; + $this->fileptr++; + $this->fetchToken(); } } - $this->fileptr = $nextptr; - $this->fetchToken(); - - // Dispose of old comment. - if ($this->commentpending) { - $this->commentpendingcounter--; - if ($this->commentpendingcounter <= 0) { - $this->commentpending = null; - } + // 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; } } @@ -338,49 +381,12 @@ protected function advance($expectedcode = null): void { * @phpstan-impure */ protected function advanceTo(int $newptr): void { - $this->fileptr = $newptr; - $this->commentpending = null; - $this->commentpendingcounter = 0; - $this->fetchToken(); - } - - /** - * Advance the token pointer when reading PHPDoc comments. - * @param array-key $expectedcode What we expect, or null if anything's OK - * @return void - * @phpstan-impure - */ - protected function advanceComment($expectedcode = null): void { - - // Check we are actually in a PHPDoc comment. - if ( - !in_array( - $this->token['code'], - [T_DOC_COMMENT_OPEN_TAG, T_DOC_COMMENT_CLOSE_TAG, T_DOC_COMMENT_STAR, - T_DOC_COMMENT_TAG, T_DOC_COMMENT_STRING, T_DOC_COMMENT_WHITESPACE] - ) - ) { - throw new \Exception(); + while ($this->fileptr < $newptr) { + $this->advance(); } - - // 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(); + if ($this->fileptr != $newptr) { + throw new \Exception("Malformed code"); } - - $this->fileptr++; - - // If we're expecting the end of the comment, then we need to advance to the next PHP code. - if ($expectedcode == T_DOC_COMMENT_CLOSE_TAG) { - while ( - $this->fileptr < count($this->tokens) - && in_array($this->tokens[$this->fileptr]['code'], [T_WHITESPACE, T_COMMENT, T_INLINE_HTML]) - ) { - $this->fileptr++; - } - } - - $this->fetchToken(); } /** @@ -389,29 +395,25 @@ protected function advanceComment($expectedcode = null): void { * @phpstan-impure */ protected function processComment(): void { - $this->commentpending = (object)['ptr' => $this->fileptr, 'tags' => []]; - - // Skip 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->advanceComment(); - } + $commentptr = $this->fileptr; + $this->commentpending = (object)['ptr' => $commentptr, 'tags' => []]; // For each tag. - while ($this->token['code'] != T_DOC_COMMENT_CLOSE_TAG) { - $tag = (object)['ptr' => $this->fileptr, 'content' => '', 'cstartptr' => null, 'cendptr' => null]; - // Fetch the tag type. + 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->advanceComment(T_DOC_COMMENT_TAG); + $this->fileptr++; + $this->fetchToken(); while ( $this->token['code'] == T_DOC_COMMENT_WHITESPACE && !in_array(substr($this->token['content'], -1), ["\n", "\r"]) ) { - $this->advanceComment(T_DOC_COMMENT_WHITESPACE); + $this->fileptr++; + $this->fetchToken(); } } else { $tagtype = ''; @@ -420,26 +422,29 @@ protected function processComment(): void { // For each line, until we reach a new tag. // Note: the logic for fixing a comment tag must exactly match this. do { - $newline = false; // Fetch line content. - while ($this->token['code'] != T_DOC_COMMENT_CLOSE_TAG && !$newline) { + $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->advanceComment(); + $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->advanceComment(); + $this->fileptr++; + $this->fetchToken(); } - } while (!in_array($this->token['code'], [T_DOC_COMMENT_CLOSE_TAG, T_DOC_COMMENT_TAG])); + } 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])) { @@ -447,7 +452,32 @@ protected function processComment(): void { } $this->commentpending->tags[$tagtype][] = $tag; } - $this->advanceComment(T_DOC_COMMENT_CLOSE_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' + ); + } + } } /** @@ -459,8 +489,8 @@ protected function processComment(): void { */ protected function fixCommentTag(object $tag, string $replacement): void { $replacementarray = explode("\n", $replacement); - $replacementcounter = 0; - $donereplacement = false; + $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(); @@ -468,25 +498,28 @@ protected function fixCommentTag(object $tag, string $replacement): void { // For each line, until we reach a new tag. // Note: the logic for this must exactly match that for processing a comment tag. do { - $newline = false; // Change line content. - while ($this->tokens[$ptr]['code'] != T_DOC_COMMENT_CLOSE_TAG && !$newline) { + $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] === "") { - throw new \Exception(); + // 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] === "")) { - throw new \Exception(); + // 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]) @@ -495,14 +528,15 @@ protected function fixCommentTag(object $tag, string $replacement): void { ) { $ptr++; } - } while (!in_array($this->tokens[$ptr]['code'], [T_DOC_COMMENT_CLOSE_TAG, T_DOC_COMMENT_TAG])); + } 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] === "")) ) { - throw new \Exception(); + // We shouldn't ever end up here. + throw new \Exception("Error during replacement"); } $this->file->fixer->endChangeset(); @@ -510,12 +544,15 @@ protected function fixCommentTag(object $tag, string $replacement): void { /** * Process a namespace declaration. - * @param \stdClass&object{namespace: string, uses: string[], templates: string[], - * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope + * @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. @@ -532,7 +569,7 @@ protected function processNamespace(object $scope): void { // Check it's right. if ($namespace != '' && $namespace[strlen($namespace) - 1] == "\\") { - throw new \Exception(); + throw new \Exception("Namespace trailing backslash"); } // Check it's fully qualified. @@ -542,13 +579,13 @@ protected function processNamespace(object $scope): void { // What kind of namespace is it? if (!in_array($this->token['code'], [T_OPEN_CURLY_BRACKET, T_SEMICOLON])) { - throw new \Exception(); + 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); + $this->processBlock($scope, 1/*block*/); } else { $scope->namespace = $namespace; $this->advance(T_SEMICOLON); @@ -557,12 +594,15 @@ protected function processNamespace(object $scope): void { /** * Process a use declaration. - * @param \stdClass&object{namespace: string, uses: string[], templates: string[], - * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope + * @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. @@ -599,11 +639,12 @@ protected function processUse(object $scope): void { // It's a group. $namespacestart = $namespace; if ($namespacestart && strrpos($namespacestart, "\\") != strlen($namespacestart) - 1) { - throw new \Exception(); + 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. @@ -617,48 +658,50 @@ protected function processUse(object $scope): void { } // Get what's being imported. - $namespaceend = ''; + $namespace = $namespacestart; while ( in_array( $this->token['code'], [T_NAME_FULLY_QUALIFIED, T_NAME_QUALIFIED, T_NAME_RELATIVE, T_NS_SEPARATOR, T_STRING] ) ) { - $namespaceend .= $this->token['content']; + $namespace .= $this->token['content']; $this->advance(); } - $namespace = $namespacestart . $namespaceend; // 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 ($this->pass == 2 && $type == 'class') { + if ($type == 'class') { $scope->uses[$alias] = $namespace; } - $more = ($this->token['code'] == T_COMMA); - if ($more) { + $maybemore = ($this->token['code'] == T_COMMA); + if ($maybemore) { $this->advance(T_COMMA); } - } while ($more); + } 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; + substr($namespace, strrpos($namespace, "\\") + 1) + : $namespace; if ($alias == '') { - throw new \Exception(); + throw new \Exception("Malformed use statement"); } $asalias = $this->processUseAsAlias(); $alias = $asalias ?? $alias; // Store it. - if ($this->pass == 2 && $type == 'class') { + if ($type == 'class') { $scope->uses[$alias] = $namespace; } } @@ -680,22 +723,32 @@ protected function processUseAsAlias(): ?string { $alias = null; if ($this->token['code'] == T_AS) { $this->advance(T_AS); - if ($this->token['code'] == T_STRING) { - $alias = $this->token['content']; - $this->advance(T_STRING); - } + $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{ + * 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): void { + protected function processClassish(object $scope, ?object $comment): void { + + $ptr = $this->fileptr; + $token = $this->token; + $this->advance(); // New scope. $scope = clone($scope); @@ -703,19 +756,29 @@ protected function processClassish(object $scope): void { $scope->closer = null; // Get details. - $name = $this->file->getDeclarationName($this->fileptr); + $name = $this->file->getDeclarationName($ptr); $name = $name ? $scope->namespace . "\\" . $name : null; - $parent = $this->file->findExtendedClassName($this->fileptr); - if ($parent && $parent[0] != "\\") { - $parent = $scope->namespace . "\\" . $parent; + $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($this->fileptr); + $interfaces = $this->file->findImplementedInterfaceNames($ptr); if (!is_array($interfaces)) { $interfaces = []; } foreach ($interfaces as $index => $interface) { if ($interface && $interface[0] != "\\") { - $interfaces[$index] = $scope->namespace . "\\" . $interface; + if (isset($scope->uses[$interface])) { + $interfaces[$index] = $scope->uses[$interface]; + } else { + $interfaces[$index] = $scope->namespace . "\\" . $interface; + } } } $scope->classname = $name; @@ -725,48 +788,56 @@ protected function processClassish(object $scope): void { // 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 ($this->comment && isset($this->comment->tags['@template'])) { - $this->processTemplates($scope); + if ($comment && isset($comment->tags['@template'])) { + $this->processTemplates($scope, $comment); } + // Check properties. - if ($this->comment) { + if ($comment) { // Check each property type. foreach (['@property', '@property-read', '@property-write'] as $tagname) { - if (!isset($this->comment->tags[$tagname])) { - $this->comment->tags[$tagname] = []; + if (!isset($comment->tags[$tagname])) { + $comment->tags[$tagname] = []; } // Check each individual property. - for ($propnum = 0; $propnum < count($this->comment->tags[$tagname]); $propnum++) { - $docpropdata = $this->typeparser->parseTypeAndVar( + foreach ($comment->tags[$tagname] as $docprop) { + $docpropparsed = $this->typeparser->parseTypeAndName( $scope, - $this->comment->tags[$tagname][$propnum]->content, - 1, - false + $docprop->content, + 1/*type and name*/, + false/*phpdoc*/ ); - if (!$docpropdata->type) { + if (!$docpropparsed->type) { $this->file->addError( 'PHPDoc class property type missing or malformed', - $this->comment->tags[$tagname][$propnum]->ptr, + $docprop->ptr, 'phpdoc_class_prop_type' ); - } elseif (!$docpropdata->var) { + } elseif (!$docpropparsed->name) { $this->file->addError( 'PHPDoc class property name missing or malformed', - $this->comment->tags[$tagname][$propnum]->ptr, + $docprop->ptr, 'phpdoc_class_prop_name' ); - } elseif ($docpropdata->fixed) { + } elseif ($docpropparsed->fixed) { $fix = $this->file->addFixableWarning( "PHPDoc class property type doesn't conform to recommended style", - $this->comment->tags[$tagname][$propnum]->ptr, + $docprop->ptr, 'phpdoc_class_prop_type_style' ); if ($fix) { $this->fixCommentTag( - $this->comment->tags[$tagname][$propnum], - $docpropdata->fixed + $docprop, + $docpropparsed->fixed ); } } @@ -775,22 +846,20 @@ protected function processClassish(object $scope): void { } } - $parametersptr = isset($this->token['parenthesis_opener']) ? $this->token['parenthesis_opener'] : null; - $blockptr = isset($this->token['scope_opener']) ? $this->token['scope_opener'] : null; - - $this->advance(); + $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->processParameters($scope); + $this->processBlock($scope, 2/*parameters*/); } // Process the content. if ($blockptr) { $this->advanceTo($blockptr); - $this->processBlock($scope); + $this->processBlock($scope, 1/*block*/); }; } @@ -804,44 +873,52 @@ protected function processClassish(object $scope): void { protected function processClassTraitUse(): void { $this->advance(T_USE); - while ( - in_array( - $this->token['code'], - [T_NAME_FULLY_QUALIFIED, T_NAME_QUALIFIED, T_NAME_RELATIVE, T_NS_SEPARATOR, T_STRING] - ) - ) { - $this->advance(); - } + $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) { - $this->advance(T_OPEN_CURLY_BRACKET); - do { - $this->advance(T_STRING); - if ($this->token['code'] == T_AS) { - $this->advance(T_AS); - while (in_array($this->token['code'], [T_PUBLIC, T_PROTECTED, T_PRIVATE])) { - $this->advance(); - } - if ($this->token['code'] == T_STRING) { - $this->advance(T_STRING); - } - } - if ($this->token['code'] == T_SEMICOLON) { - $this->advance(T_SEMICOLON); + 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."); } - } while ($this->token['code'] != T_CLOSE_CURLY_BRACKET); - $this->advance(T_CLOSE_CURLY_BRACKET); - } + $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{ + * 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): void { + protected function processFunction(object $scope, ?object $comment): void { + + $ptr = $this->fileptr; + $token = $this->token; + $this->advance(); // New scope. $scope = clone($scope); @@ -849,173 +926,239 @@ protected function processFunction(object $scope): void { $scope->closer = null; // Get details. - // Can't fetch name for arrow functions. But we're not doing checks that need the name any more. - // $name = $this->file->getDeclarationName($this->fileptr); - $parameters = $this->file->getMethodParameters($this->fileptr); - $properties = $this->file->getMethodProperties($this->fileptr); + $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 ($name && !$this->comment) { + if ( + CHECK_HAS_DOCS && $name && !$comment + && (count($parameters) > 0 || strtolower(trim($properties['return_type'])) != 'void') + ) { $this->file->addWarning( 'PHPDoc function is not documented', - $this->fileptr, + $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 ($this->comment && isset($this->comment->tags['@template'])) { - $this->processTemplates($scope); + if ($comment && isset($comment->tags['@template'])) { + $this->processTemplates($scope, $comment); } // Check parameter types. - if ($this->comment && isset($parameters)) { - if (!isset($this->comment->tags['@param'])) { - $this->comment->tags['@param'] = []; - } - if (count($this->comment->tags['@param']) != count($parameters)) { - $this->file->addError( - "PHPDoc number of function @param tags doesn't match actual number of parameters", - $this->comment->ptr, - 'phpdoc_fun_param_count' + 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; + } } - // Check each individual parameter. - for ($varnum = 0; $varnum < count($this->comment->tags['@param']); $varnum++) { - $docparamdata = $this->typeparser->parseTypeAndVar( + 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, - $this->comment->tags['@param'][$varnum]->content, - 2, - false + $docparam->content, + 2/*type, modifiers & ..., and name*/, + false/*phpdoc*/ ); - if (!$docparamdata->type) { + if (!$docparamparsed->type) { $this->file->addError( - 'PHPDoc function parameter %s type missing or malformed', - $this->comment->tags['@param'][$varnum]->ptr, - 'phpdoc_fun_param_type', - [$varnum + 1] + 'PHPDoc function parameter type missing or malformed', + $docparam->ptr, + 'phpdoc_fun_param_type' ); - } elseif (!$docparamdata->var) { + } elseif (!$docparamparsed->name) { $this->file->addError( - 'PHPDoc function parameter %s name missing or malformed', - $this->comment->tags['@param'][$varnum]->ptr, - 'phpdoc_fun_param_name', - [$varnum + 1] + 'PHPDoc function parameter name missing or malformed', + $docparam->ptr, + 'phpdoc_fun_param_name' ); - } elseif ($varnum < count($parameters)) { - // Compare docs against actual parameters. - $paramdata = $this->typeparser->parseTypeAndVar( - $scope, - $parameters[$varnum]['content'], - 3, - true + } 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' ); - if ($paramdata->var != $docparamdata->var) { - // Function parameter names don't match. - // Don't do any more checking, because the parameters might be in the wrong order. + } else { + // Compare docs against actual parameter. + + $paramparsed = $paramparsedarray[$docparamparsed->name]; + + if (isset($docparamsmatched[$docparamparsed->name])) { $this->file->addError( - 'PHPDoc function parameter %s name mismatch', - $this->comment->tags['@param'][$varnum]->ptr, - 'phpdoc_fun_param_name_mismatch', - [$varnum + 1] + 'PHPDoc function parameter repeated', + $docparam->ptr, + 'phpdoc_fun_param_type_repeat' ); - } else { - if (!$this->typeparser->comparetypes($paramdata->type, $docparamdata->type)) { - $this->file->addError( - 'PHPDoc function parameter %s type mismatch', - $this->comment->tags['@param'][$varnum]->ptr, - 'phpdoc_fun_param_type_mismatch', - [$varnum + 1] - ); - } elseif ($docparamdata->fixed) { - $fix = $this->file->addFixableWarning( - "PHPDoc function parameter %s type doesn't conform to recommended style", - $this->comment->tags['@param'][$varnum]->ptr, - 'phpdoc_fun_param_type_style', - [$varnum + 1] - ); - if ($fix) { - $this->fixCommentTag( - $this->comment->tags['@param'][$varnum], - $docparamdata->fixed - ); - } - } - if ($paramdata->passsplat != $docparamdata->passsplat) { - $this->file->addWarning( - 'PHPDoc function parameter %s splat mismatch', - $this->comment->tags['@param'][$varnum]->ptr, - 'phpdoc_fun_param_pass_splat_mismatch', - [$varnum + 1] + } + $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' + ); + } elseif ($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 (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 ($this->comment && isset($properties)) { - if (!isset($this->comment->tags['@return'])) { - $this->comment->tags['@return'] = []; + 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'] = []; } - // The old checker didn't check this. - /*if (count($this->comment->tags['@return']) < 1 && $name != '__construct') { - $this->file->addError( + if ( + 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', - $this->fileptr, + $comment->ptr, 'phpdoc_fun_ret_missing' ); - } else*/ - if (count($this->comment->tags['@return']) > 1) { + } elseif (count($comment->tags['@return']) > 1) { $this->file->addError( 'PHPDoc multiple function @return tags--Put in one tag, seperated by vertical bars |', - $this->comment->tags['@return'][1]->ptr, + $comment->tags['@return'][1]->ptr, 'phpdoc_fun_ret_multiple' ); } - $retdata = $properties['return_type'] ? - $this->typeparser->parseTypeAndVar( - $scope, - $properties['return_type'], - 0, - true - ) - : (object)['type' => 'mixed']; // Check each individual return tag, in case there's more than one. - for ($retnum = 0; $retnum < count($this->comment->tags['@return']); $retnum++) { - $docretdata = $this->typeparser->parseTypeAndVar( + foreach ($comment->tags['@return'] as $docret) { + $docretparsed = $this->typeparser->parseTypeAndName( $scope, - $this->comment->tags['@return'][$retnum]->content, - 0, - false + $docret->content, + 0/*type only*/, + false/*phpdoc*/ ); - if (!$docretdata->type) { + if (!$docretparsed->type) { $this->file->addError( 'PHPDoc function return type missing or malformed', - $this->comment->tags['@return'][$retnum]->ptr, + $docret->ptr, 'phpdoc_fun_ret_type' ); - } elseif (!$this->typeparser->comparetypes($retdata->type, $docretdata->type)) { + } elseif (!$this->typeparser->comparetypes($retparsed->type, $docretparsed->type)) { $this->file->addError( 'PHPDoc function return type mismatch', - $this->comment->tags['@return'][$retnum]->ptr, + $docret->ptr, 'phpdoc_fun_ret_type_mismatch' ); - } elseif ($docretdata->fixed) { + } elseif ($docretparsed->fixed) { $fix = $this->file->addFixableWarning( "PHPDoc function return type doesn't conform to recommended style", - $this->comment->tags['@return'][$retnum]->ptr, + $docret->ptr, 'phpdoc_fun_ret_type_style' ); if ($fix) { $this->fixCommentTag( - $this->comment->tags['@return'][$retnum], - $docretdata->fixed + $docret, + $docretparsed->fixed ); } } @@ -1023,91 +1166,54 @@ protected function processFunction(object $scope): void { } } - $parametersptr = isset($this->token['parenthesis_opener']) ? $this->token['parenthesis_opener'] : null; - $blockptr = isset($this->token['scope_opener']) ? $this->token['scope_opener'] : null; - - $this->advance(); - // Parameters could contain anonymous classes or functions. if ($parametersptr) { $this->advanceTo($parametersptr); - $this->processParameters($scope); + $this->processBlock($scope, 2); } // Content. if ($blockptr) { $this->advanceTo($blockptr); - $this->processBlock($scope); + $this->processBlock($scope, 1); }; } - /** - * Search parameter default values for anonymous classes and functions - * @param \stdClass&object{namespace: string, uses: string[], templates: string[], - * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope - * @return void - * @phpstan-impure - */ - protected function processParameters(object $scope): void { - - $scope = clone($scope); - $scope->closer = $this->token['parenthesis_closer']; - $this->advance(T_OPEN_PARENTHESIS); - - while (true) { - // Skip irrelevant tokens. - while ( - !in_array($this->token['code'], [T_ANON_CLASS, T_CLOSURE, T_FN]) - && $this->fileptr < $scope->closer - ) { - $this->advance(); - } - - if ($this->fileptr >= $scope->closer) { - // End of the parameters. - break; - } elseif ($this->token['code'] == T_ANON_CLASS) { - // Classish thing. - $this->processClassish($scope); - } elseif (in_array($this->token['code'], [T_CLOSURE, T_FN])) { - // Function. - $this->processFunction($scope); - } else { - // Something unrecognised. - throw new \Exception(); - } - } - $this->advance(T_CLOSE_PARENTHESIS); - } - - /** * Process templates. - * @param \stdClass&object{namespace: string, uses: string[], templates: string[], - * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope + * @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): void { - foreach ($this->comment->tags['@template'] as $templatetag) { - $templatedata = $this->typeparser->parseTemplate($scope, $templatetag->content); - if (!$templatedata->var) { - $this->file->addError('PHPDoc template name missing or malformed', $templatetag->ptr, 'phpdoc_template_name'); - } elseif (!$templatedata->type) { - $this->file->addError('PHPDoc template type missing or malformed', $templatetag->ptr, 'phpdoc_template_type'); - $scope->templates[$templatedata->var] = 'never'; + 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[$templatedata->var] = $templatedata->type; - if ($templatedata->fixed) { + $scope->templates[$doctemplateparsed->name] = $doctemplateparsed->type; + if ($doctemplateparsed->fixed) { $fix = $this->file->addFixableWarning( "PHPDoc tempate type doesn't conform to recommended style", - $templatetag->ptr, + $doctemplate->ptr, 'phpdoc_template_type_style' ); if ($fix) { $this->fixCommentTag( - $templatetag, - $templatedata->fixed + $doctemplate, + $doctemplateparsed->fixed ); } } @@ -1117,12 +1223,20 @@ protected function processTemplates(object $scope): void { /** * Process a variable. - * @param \stdClass&object{namespace: string, uses: string[], templates: string[], - * classname: ?string, parentname: ?string, type: string, closer: ?int} $scope + * @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($scope): void { + protected function processVariable(object $scope, ?object $comment): void { // Parse var/const token. $const = ($this->token['code'] == T_CONST); @@ -1134,6 +1248,7 @@ protected function processVariable($scope): void { // 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'], @@ -1148,71 +1263,76 @@ protected function processVariable($scope): void { // Check name. if ($this->token['code'] != ($const ? T_STRING : T_VARIABLE)) { - throw new \Exception(); + throw new \Exception("Expected declaration."); } - // Type checking. + // 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; + $this->file->getMemberProperties($this->fileptr) + : null; + $vartype = ($properties && $properties['type']) ? $properties['type'] : 'mixed'; - if (!$this->comment && $scope->type == 'classish') { + if (CHECK_HAS_DOCS && !$comment && $scope->type == 'classish') { // Require comments for class variables and constants. - /*$this->file->addWarning( + $this->file->addWarning( 'PHPDoc variable or constant is not documented', $this->fileptr, 'phpdoc_var_doc_missing' - );*/ - } elseif ($this->comment) { - if (!isset($this->comment->tags['@var'])) { - $this->comment->tags['@var'] = []; + ); + } 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 or multiple vars. - /*if (count($this->comment->tags['@var']) < 1) { - $this->file->addError('PHPDoc missing @var tag', $this->comment->ptr, 'phpdoc_var_missing'); - } elseif (count($this->comment->tags['@var']) > 1) { - $this->file->addError('PHPDoc multiple @var tags', $this->comment->tags['@var'][1]->ptr, 'phpdoc_var_multiple'); - }*/ + + // Missing var tag. + if (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. - $vardata = ($properties && $properties['type']) ? - $this->typeparser->parseTypeAndVar( - $scope, - $properties['type'], - 0, - true - ) - : (object)['type' => 'mixed']; - for ($varnum = 0; $varnum < count($this->comment->tags['@var']); $varnum++) { - $docvardata = $this->typeparser->parseTypeAndVar( + $varparsed = $this->typeparser->parseTypeAndName( + $scope, + $vartype, + 0/*type only*/, + true/*native php*/ + ); + foreach ($comment->tags['@var'] as $docvar) { + $docvarparsed = $this->typeparser->parseTypeAndName( $scope, - $this->comment->tags['@var'][$varnum]->content, - 0, - false + $docvar->content, + 0/*type only*/, + false/*phpdoc*/ ); - if (!$docvardata->type) { + if (!$docvarparsed->type) { $this->file->addError( 'PHPDoc var type missing or malformed', - $this->comment->tags['@var'][$varnum]->ptr, + $docvar->ptr, 'phpdoc_var_type' ); - } elseif (!$this->typeparser->comparetypes($vardata->type, $docvardata->type)) { + } elseif (!$this->typeparser->comparetypes($varparsed->type, $docvarparsed->type)) { $this->file->addError( 'PHPDoc var type mismatch', - $this->comment->tags['@var'][$varnum]->ptr, + $docvar->ptr, 'phpdoc_var_type_mismatch' ); - } elseif ($docvardata->fixed) { + } elseif ($docvarparsed->fixed) { $fix = $this->file->addFixableWarning( "PHPDoc var type doesn't conform to recommended style", - $this->comment->tags['@var'][$varnum]->ptr, + $docvar->ptr, 'phpdoc_var_type_style' ); if ($fix) { $this->fixCommentTag( - $this->comment->tags['@var'][$varnum], - $docvardata->fixed + $docvar, + $docvarparsed->fixed ); } } @@ -1222,9 +1342,68 @@ protected function processVariable($scope): void { $this->advance(); - if (!in_array($this->token['code'], [T_EQUAL, T_COMMA, T_SEMICOLON])) { - throw new \Exception(); + 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' + ); + } elseif ($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(); } } diff --git a/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php index 8fa40e9..759a76c 100644 --- a/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php +++ b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php @@ -62,40 +62,82 @@ public function testPHPDocTypesCorrectness( */ public static function provider(): array { return [ + /*'PHPDocTypes docs missing wrong' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_docs_missing_wrong', + '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_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 method union types right' => [ - 'fixture' => 'phpdoctypes_method_union_types_right', + 'fixture' => 'phpdoctypes/phpdoctypes_method_union_types_right', 'errors' => [], 'warnings' => [], ], - 'PHPDocTypes tags general right' => [ - 'fixture' => 'phpdoctypes_tags_general_right', + 'PHPDocTypes namespace right' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_namespace_right', 'errors' => [], 'warnings' => [], ], - 'PHPDocTypes tags general wrong' => [ - 'fixture' => 'phpdoctypes_tags_general_wrong', + 'PHPDocTypes parse wrong' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_parse_wrong', 'errors' => [ - 41 => "PHPDoc function parameter 1 type missing or malformed", - 42 => "PHPDoc function parameter 2 type missing or malformed", - 48 => "PHPDoc number of function @param tags doesn't match actual number of parameters", - 58 => "PHPDoc number of function @param tags doesn't match actual number of parameters", - 65 => "PHPDoc number of function @param tags doesn't match actual number of parameters", - 75 => "PHPDoc number of function @param tags doesn't match actual number of parameters", - 88 => 'PHPDoc function parameter 2 type mismatch', - 97 => 'PHPDoc function parameter 1 type mismatch', - 107 => 'PHPDoc function parameter 1 type mismatch', - 118 => 'PHPDoc function parameter 2 type mismatch', - 127 => 'PHPDoc function return type missing or malformed', + 91 => "PHPDoc function parameter type mismatch", ], + 'warnings' => [], + ], + 'PHPDocTypes style wrong' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_style_wrong', + 'errors' => [], 'warnings' => [ - 108 => 'PHPDoc function parameter 2 splat mismatch', + 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", ], ], + 'PHPDocTypes tags general right' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_tags_general_right', + 'errors' => [], + 'warnings' => [], + ], ]; } } diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_wrong.php new file mode 100644 index 0000000..21aa95a --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_wrong.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_general_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php similarity index 71% rename from moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_general_right.php rename to moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php index 350b740..2ed78bd 100644 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_general_right.php +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php @@ -17,21 +17,23 @@ /** * A collection of valid types for testing * - * This file should have no errors when checked with either PHPStan or Psalm. + * 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 (or CC BY-SA v4 or later) + * @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; -defined('MOODLE_INTERNAL') || die(); +use stdClass as myStdClass, Exception; +use MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\ {PHPDocTypesSniffTest}; -use stdClass as myStdClass; +?> + $x * @return void */ function namespaces(php_valid $x): void { @@ -72,7 +81,7 @@ function uses(myStdClass $x): void { /** * Parents recognised - * @param php_valid $x + * @param php_valid $x * @return void */ function parents(php_valid_parent $x): void { @@ -80,12 +89,37 @@ function parents(php_valid_parent $x): void { /** * Interfaces recognised - * @param php_valid $x + * @param php_valid $x * @return void */ function interfaces(php_valid_interface $x): void { } + /** + * Class templates recognised + * @param T $x + * @return void + */ + function class_templates(int $x): void { + } + + /** + * Function templates recognised + * @template T2 of int + * @param T2 $x + * @return void + */ + function function_templates(int $x): void { + } + + /** + * Visibility accepted + * @param int $x + * @return void + */ + public function visibility(int $x): void { + } + /** * Multiline comment * @param object{ @@ -97,3 +131,6 @@ function interfaces(php_valid_interface $x): void { function multiline_comment(object $x): void { } } + +// Ignore things that don't concern us. +$x = 0; diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_wrong.php new file mode 100644 index 0000000..1c703f0 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_wrong.php @@ -0,0 +1,103 @@ +. + +/** + * 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_method_union_types_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_method_union_types_right.php similarity index 100% rename from moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_method_union_types_right.php rename to moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_method_union_types_right.php 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_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php new file mode 100644 index 0000000..920accd --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.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 Integer $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(int $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_wrong.php.fixed b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php.fixed new file mode 100644 index 0000000..70ceeb4 --- /dev/null +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.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 int $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(int $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/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_tags_general_right.php similarity index 96% rename from moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_right.php rename to moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_tags_general_right.php index 7110351..7b97d5c 100644 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_right.php +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_tags_general_right.php @@ -22,8 +22,6 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); - global $CFG; /** @@ -41,6 +39,7 @@ class fixturing_general { * @param string|bool $one * @param bool $two * @param array $three + * @return void */ public function correct_param_types($one, bool $two, array $three): void { echo "yay!"; @@ -52,6 +51,7 @@ public function correct_param_types($one, bool $two, array $three): void { * @param string|bool $one * @param bool $two * @param array $three + * @return void */ public function correct_param_types1($one, bool $two, array $three): void { echo "yay!"; @@ -62,6 +62,7 @@ public function correct_param_types1($one, bool $two, array $three): void { * * @param string $one * @param bool $two + * @return void */ public function correct_param_types2($one, $two): void { echo "yay!"; @@ -73,6 +74,7 @@ public function correct_param_types2($one, $two): void { * @param string|null $one * @param bool $two * @param array $three + * @return void */ public function correct_param_types3(?string $one, bool $two, array $three): void { echo "yay!"; @@ -84,6 +86,7 @@ public function correct_param_types3(?string $one, bool $two, array $three): voi * @param string $one * @param bool $two * @param int[]|null $three + * @return void */ public function correct_param_types4($one, bool $two, array $three = null): void { echo "yay!"; @@ -94,6 +97,7 @@ public function correct_param_types4($one, bool $two, array $three = null): void * * @param string $one * @param mixed ...$params one or more params + * @return void */ public function correct_param_types5(string $one, ...$params): void { echo "yay!"; diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_wrong.php deleted file mode 100644 index 68b9adb..0000000 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes_tags_general_wrong.php +++ /dev/null @@ -1,133 +0,0 @@ -. - -/** - * A fixture to verify various phpdoc tags in a general location. - * - * @package local_moodlecheck - * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -defined('MOODLE_INTERNAL') || die(); - -global $CFG; - -/** - * A fixture to verify various phpdoc tags in a general location. - * - * @package local_moodlecheck - * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class fixturing_general { - - /** - * Incomplete param annotation (type is missing). - * - * @param $one - * @param $two - */ - public function incomplete_param_annotation($one, $two) { - echo "yoy!"; - } - - /** - * Missing param definition. - * - * @param string $one - * @param bool $two - */ - public function missing_param_defintion() { - echo "yoy!"; - } - - /** - * Missing param annotation. - */ - public function missing_param_annotation($one, $two) { - echo "yoy!"; - } - - /** - * Incomplete param definition. - * - * @param string $one - * @param bool $two - */ - public function incomplete_param_definition(string $one) { - echo "yoy!"; - } - - /** - * Incomplete param annotation (annotation is missing). - * - * @param string $one - */ - public function incomplete_param_annotation1(string $one, $two) { - echo "yoy!"; - } - - /** - * Mismatch param types. - * - * @param string $one - * @param bool $two - */ - public function mismatch_param_types(string $one, array $two = []) { - echo "yoy!"; - } - - /** - * Mismatch param types. - * - * @param string|bool $one - * @param bool $two - */ - public function mismatch_param_types1(string $one, bool $two) { - echo "yoy!"; - } - - /** - * Mismatch param types. - * - * @param string|bool $one - * @param bool $params - */ - public function mismatch_param_types2(string $one, ...$params) { - echo "yoy!"; - } - - /** - * Mismatch param types. - * - * @param string $one - * @param int[] $params - */ - public function mismatch_param_types3(string $one, int $params) { - echo "yoy!"; - } - - /** - * Incomplete return annotation (type is missing). - * - * @return - */ - public function incomplete_return_annotation() { - echo "yoy!"; - } - -} diff --git a/moodle/Tests/Util/PHPDocTypeParserTest.php b/moodle/Tests/Util/PHPDocTypeParserTest.php index 02a9c91..bbbfb2b 100644 --- a/moodle/Tests/Util/PHPDocTypeParserTest.php +++ b/moodle/Tests/Util/PHPDocTypeParserTest.php @@ -66,15 +66,16 @@ public static function provider(): array { 'fixture' => 'phpdoctypes/phpdoctypes_all_types_right', 'errors' => [], 'warnings' => [ - 128 => "PHPDoc function parameter 1 type doesn't conform to recommended style", - 136 => "PHPDoc function parameter 1 type doesn't conform to recommended style", + 129 => "PHPDoc function parameter type doesn't conform to recommended style", + 138 => "PHPDoc function parameter type doesn't conform to recommended style", + 233 => "PHPDoc function return type doesn't conform to recommended style", ], ], 'PHPDocTypes parse wrong' => [ 'fixture' => 'phpdoctypes/phpdoctypes_parse_wrong', 'errors' => [ - 43 => 'PHPDoc function parameter 1 name missing or malformed', - 50 => 'PHPDoc function parameter 1 name missing or malformed', + 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', @@ -94,9 +95,9 @@ public static function provider(): array { 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 1 type missing or malformed', - 125 => 'PHPDoc var type missing or malformed', - 128 => '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' => [], ], diff --git a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php index ed5c7a4..737c6ab 100644 --- a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php +++ b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php @@ -24,10 +24,10 @@ * @package local_codechecker * @copyright 2023 Otago Polytechnic * @author James Calder - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later (or CC BY-SA v4 or later) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause */ -defined('MOODLE_INTERNAL') || die(); +use stdClass as MyStdClass; /** * A parent class @@ -47,9 +47,9 @@ interface types_valid_interface { * @package local_codechecker * @copyright 2023 Otago Polytechnic * @author James Calder - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later (or CC BY-SA v4 or later) + * @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 { +class types_valid extends types_valid_parent implements types_valid_interface { /** @var array */ public const ARRAY_CONST = [ 1 => 'one', 2 => 'two' ]; @@ -117,6 +117,7 @@ public function non_native_types($parameterisedarray, $resource, $static, $param * Parameter modifiers * @param object &$reference * @param int ...$splat + * @return void */ public function parameter_modifiers( object &$reference, @@ -127,6 +128,7 @@ public function parameter_modifiers( * Boolean types * @param bool|boolean $bool * @param true|false $literal + * @return void */ public function boolean_types(bool $bool, bool $literal): void { } @@ -138,6 +140,7 @@ public function boolean_types(bool $bool, bool $literal): void { * @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 { @@ -148,6 +151,7 @@ public function integer_types(int $int, int $intrange1, int $intrange2, * @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 { } @@ -156,6 +160,7 @@ public function integer_types_complex(int $literal2, int $intmask2, int $intmask * Float types * @param float|double $float * @param 1.0|-1.0 $literal + * @return void */ public function float_types(float $float, float $literal): void { } @@ -166,16 +171,17 @@ public function float_types(float $float, float $literal): void { * @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 class-string $classstring2 * @param '\'' $stringwithescape + * @return void */ - public function string_types_complex(string $classstring2, string $stringwithescape): void { + public function string_types_complex(string $stringwithescape): void { } /** @@ -185,6 +191,7 @@ public function string_types_complex(string $classstring2, string $stringwithesc * @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 { @@ -192,7 +199,8 @@ public function array_types(array $genarray1, array $genarray2, array $list, /** * Array types complex - * @param array|array<1|2, string>|array $genarray3 + * @param array $genarray3 + * @return void */ public function array_types_complex(array $genarray3): void { } @@ -206,6 +214,7 @@ public function array_types_complex(array $genarray3): void { * @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 { @@ -214,6 +223,7 @@ public function object_types(object $object, object $shapes1, object $shapes2, o /** * Object types complex * @param Traversable<1|2, types_valid|types_valid_interface>|Traversable $traversable2 + * @return void */ public function object_types_complex(object $traversable2): void { } @@ -243,6 +253,7 @@ public function void_type( /** * User-defined type * @param types_valid|\types_valid $class + * @return void */ public function user_defined_type(types_valid $class): void { } @@ -251,9 +262,10 @@ 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(float...): (int|null) $callable3 + * @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 { @@ -264,6 +276,7 @@ public function callable_types(callable $callable1, callable $callable2, callabl * @param array $array * @param iterable|iterable $iterable1 * @param Traversable|Traversable $traversable1 + * @return void */ public function iterable_types(iterable $array, iterable $iterable1, iterable $traversable1): void { } @@ -272,6 +285,7 @@ public function iterable_types(iterable $array, iterable $iterable1, iterable $t * 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 { } @@ -280,6 +294,7 @@ public function iterable_types_complex(iterable $iterable2, iterable $traversabl * 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 { } @@ -288,6 +303,7 @@ 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 { } @@ -329,6 +345,7 @@ public function conditional_return_complex_2($x) { * @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 { @@ -341,7 +358,7 @@ public function constant_enumerations(bool $bool, int $int1, int $int2, int $int * @param types_valid&object{additionalproperty: string} $intersection * @param (int) $brackets * @param int[] $arraysuffix - + * @return void */ public function basic_structure( ?int $nullable, @@ -369,6 +386,7 @@ public function basic_structure( * @param (int)[] $bracketarray1 * @param (int[]) $bracketarray2 * @param int|(types_valid&object{additionalproperty: string}) $dnf + * @return void */ public function structure_combos( $multipleunion, @@ -394,6 +412,7 @@ public function structure_combos( * @param types_valid $basic * @param self|static|$this $relative1 * @param types_valid $relative2 + * @return void */ public function inheritance( types_valid_parent $basic, @@ -402,6 +421,23 @@ public function inheritance( ): 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 @@ -410,6 +446,7 @@ public function inheritance( * @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, @@ -423,6 +460,7 @@ public function builtin_classes( * @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 diff --git a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php index d7c10b0..1389c6f 100644 --- a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php +++ b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_parse_wrong.php @@ -23,24 +23,23 @@ * @package local_codechecker * @copyright 2023 Otago Polytechnic * @author James Calder - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later (or CC BY-SA v4 or later) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later, CC BY-SA v4 or later, and BSD-3-Clause */ -defined('MOODLE_INTERNAL') || die(); - /** * 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 (or CC BY-SA v4 or later) + * @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 { } @@ -48,6 +47,7 @@ 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 { } @@ -71,7 +71,7 @@ public function expecting_var_saw_other(int $x): void { /** @var "\*/ public $stringhasescapewithnofollowingchar; - /** @var array-key&(int|string) Non-DNF type (passes PHPStan) */ + /** @var types_invalid&(a|b) Non-DNF type (passes PHPStan) */ public $nondnftype; /** @var int&string Invalid intersection */ @@ -117,6 +117,7 @@ public function expecting_var_saw_other(int $x): void { /** * Class name has trailing slash * @param types_invalid\ $x + * @return void */ public function class_name_has_trailing_slash(object $x): void { } diff --git a/moodle/Util/PHPDocTypeParser.php b/moodle/Util/PHPDocTypeParser.php index 428b85c..bac8bfc 100644 --- a/moodle/Util/PHPDocTypeParser.php +++ b/moodle/Util/PHPDocTypeParser.php @@ -23,7 +23,7 @@ * * @copyright 2023-2024 Otago Polytechnic * @author James Calder - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later (or CC BY-SA v4 or later) + * @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); @@ -165,13 +165,13 @@ public function __construct(?array $artifacts = null) { * 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 var 2=also modifiers (& ...) 3=also default + * @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, var: ?non-empty-string, + * @return object{type: ?non-empty-string, passsplat: string, name: ?non-empty-string, * rem: string, fixed: ?string} * the simplified type, pass by reference & splat, variable name, remaining text, and fixed text */ - public function parseTypeAndVar(?object $scope, string $text, int $getwhat, bool $gowide): object { + public function parseTypeAndName(?object $scope, string $text, int $getwhat, bool $gowide): object { // Initialise variables. if ($scope) { @@ -215,19 +215,18 @@ public function parseTypeAndVar(?object $scope, string $text, int $getwhat, bool $this->parseToken('&'); } if ($this->next == '...') { - // Add to variable name for code smell check. $passsplat .= $this->parseToken('...'); } } - // Try to parse variable and default value. + // 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}\"."); } - $variable = $this->parseToken(); + $name = $this->parseToken(); if ( !($this->next == null || $getwhat >= 3 && $this->next == '=' || ctype_space(substr($this->text, $this->nexts[0]->startpos - 1, 1)) @@ -236,6 +235,8 @@ public function parseTypeAndVar(?object $scope, string $text, int $getwhat, bool // 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 == '=' @@ -249,13 +250,13 @@ public function parseTypeAndVar(?object $scope, string $text, int $getwhat, bool } catch (\Exception $e) { $this->nexts = $savednexts; $this->next = $this->next(); - $variable = null; + $name = null; } } else { - $variable = null; + $name = null; } - return (object)['type' => $type, 'passsplat' => $passsplat, 'var' => $variable, + return (object)['type' => $type, 'passsplat' => $passsplat, 'name' => $name, 'rem' => trim(substr($text, $this->nexts[0]->startpos)), 'fixed' => $type ? $this->getFixed() : null]; } @@ -264,7 +265,7 @@ public function parseTypeAndVar(?object $scope, string $text, int $getwhat, bool * 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, var: ?non-empty-string, rem: string, fixed: ?string} + * @return object{type: ?non-empty-string, name: ?non-empty-string, rem: string, fixed: ?string} * the simplified type, template name, remaining text, and fixed text */ public function parseTemplate(?object $scope, string $text): object { @@ -287,9 +288,9 @@ public function parseTemplate(?object $scope, string $text): object { if (!($this->next != null && (ctype_alpha($this->next[0]) || $this->next[0] == '_'))) { throw new \Exception("Error parsing type, expected variable, saw \"{$this->next}\"."); } - $variable = $this->parseToken(); + $name = $this->parseToken(); if ( - !($this->next == null || $this->next == 'of' + !($this->next == null || ctype_space(substr($this->text, $this->nexts[0]->startpos - 1, 1)) || in_array($this->next, [',', ';', ':', '.'])) ) { @@ -299,11 +300,11 @@ public function parseTemplate(?object $scope, string $text): object { } catch (\Exception $e) { $this->nexts = $savednexts; $this->next = $this->next(); - $variable = null; + $name = null; } - if ($this->next == 'of') { - $this->parseToken('of'); + if ($this->next == 'of' || $this->next == 'as') { + $this->parseToken(); // Try to parse type. $savednexts = $this->nexts; try { @@ -325,7 +326,7 @@ public function parseTemplate(?object $scope, string $text): object { $type = 'mixed'; } - return (object)['type' => $type, 'var' => $variable, + return (object)['type' => $type, 'name' => $name, 'rem' => trim(substr($text, $this->nexts[0]->startpos)), 'fixed' => $type ? $this->getFixed() : null]; } @@ -416,7 +417,7 @@ protected function superTypes(string $basetype): array { } else { $supertypes = ['object']; $supertypequeue = [$basetype]; - $ignore = true; + $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)) { @@ -838,7 +839,7 @@ protected function parseBasicType(): string { $strtype = strtolower($this->parseToken()); if ($strtype == 'class-string' && $this->next == '<') { $this->parseToken('<'); - $stringtype = $this->parseAnyType(); + $stringtype = $this->parseBasicType(); if (!$this->compareTypes('object', $stringtype)) { throw new \Exception("Error parsing type, class-string type isn't class."); } @@ -938,7 +939,7 @@ protected function parseBasicType(): string { $type = 'resource'; } elseif (in_array($lowernext, ['never', 'never-return', 'never-returns', 'no-return'])) { // Never. - $this->correctToken($lowernext); + $this->correctToken('never'); $this->parseToken(); $type = 'never'; } elseif ($lowernext == 'null') { From 7f14992d52b6f94c121ba5d0cdf606bd50f3b3b3 Mon Sep 17 00:00:00 2001 From: James C <5689414+james-cnz@users.noreply.github.com> Date: Tue, 2 Apr 2024 17:20:43 +1300 Subject: [PATCH 4/4] Enforce PHP-FIG standard --- moodle/Sniffs/Commenting/PHPDocTypesSniff.php | 250 ++++++++++------ .../Commenting/PHPDocTypesSniffTest.php | 30 +- .../phpdoctypes/phpdoctypes_complex_warn.php | 58 ++++ ....php => phpdoctypes_docs_missing_warn.php} | 0 .../phpdoctypes/phpdoctypes_general_right.php | 36 +-- .../phpdoctypes_method_union_types_right.php | 92 ------ ...e_wrong.php => phpdoctypes_style_warn.php} | 4 +- ...fixed => phpdoctypes_style_warn.php.fixed} | 4 +- .../phpdoctypes_tags_general_right.php | 142 --------- moodle/Tests/Util/PHPDocTypeParserTest.php | 81 +++++- ...right.php => phpdoctypes_complex_warn.php} | 4 +- .../phpdoctypes/phpdoctypes_simple_right.php | 269 ++++++++++++++++++ moodle/Util/PHPDocTypeParser.php | 53 +++- 13 files changed, 657 insertions(+), 366 deletions(-) create mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_complex_warn.php rename moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/{phpdoctypes_docs_missing_wrong.php => phpdoctypes_docs_missing_warn.php} (100%) delete mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_method_union_types_right.php rename moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/{phpdoctypes_style_wrong.php => phpdoctypes_style_warn.php} (93%) rename moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/{phpdoctypes_style_wrong.php.fixed => phpdoctypes_style_warn.php.fixed} (93%) delete mode 100644 moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_tags_general_right.php rename moodle/Tests/Util/fixtures/phpdoctypes/{phpdoctypes_all_types_right.php => phpdoctypes_complex_warn.php} (99%) create mode 100644 moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_simple_right.php diff --git a/moodle/Sniffs/Commenting/PHPDocTypesSniff.php b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php index ca3131c..fbdad43 100644 --- a/moodle/Sniffs/Commenting/PHPDocTypesSniff.php +++ b/moodle/Sniffs/Commenting/PHPDocTypesSniff.php @@ -27,9 +27,6 @@ namespace MoodleHQ\MoodleCS\moodle\Sniffs\Commenting; -define('DEBUG_MODE', false); -define('CHECK_HAS_DOCS', false); - use PHP_CodeSniffer\Sniffs\Sniff; use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Util\Tokens; @@ -40,6 +37,10 @@ */ 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; @@ -115,8 +116,8 @@ public function process(File $phpcsfile, $stackptr): int { } 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(), + "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' ); @@ -158,7 +159,7 @@ 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 (DEBUG_MODE && $this->token['code'] != T_OPEN_TAG) { + if (static::DEBUG_MODE && $this->token['code'] != T_OPEN_TAG) { // We shouldn't ever end up here. throw new \Exception("Expected PHP open tag"); } @@ -300,14 +301,14 @@ protected function processBlock(object $scope, int $type): void { } } catch (\Exception $e) { // Just give up on whatever we're doing and try again, unless in debug mode. - if (DEBUG_MODE) { + if (static::DEBUG_MODE) { throw $e; } } } // Check we are at the end of the scope. - if (DEBUG_MODE && $this->fileptr != $scope->closer) { + 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. @@ -472,7 +473,7 @@ protected function checkNo(object $comment, array $tagnames): void { foreach ($tagnames as $tagname) { if (isset($comment->tags[$tagname])) { $this->file->addWarning( - 'PHPDoc misplaced tag', + "PHPDoc misplaced tag", $comment->tags[$tagname][0]->ptr, 'phpdoc_tag_misplaced' ); @@ -818,28 +819,38 @@ protected function processClassish(object $scope, ?object $comment): void { ); if (!$docpropparsed->type) { $this->file->addError( - 'PHPDoc class property type missing or malformed', + "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', + "PHPDoc class property name missing or malformed", $docprop->ptr, 'phpdoc_class_prop_name' ); - } elseif ($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 + } 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 + ); + } + } } } } @@ -943,11 +954,11 @@ protected function processFunction(object $scope, ?object $comment): void { if ($this->pass == 2) { // Check for missing docs if not anonymous. if ( - CHECK_HAS_DOCS && $name && !$comment + static::CHECK_HAS_DOCS && $name && !$comment && (count($parameters) > 0 || strtolower(trim($properties['return_type'])) != 'void') ) { $this->file->addWarning( - 'PHPDoc function is not documented', + "PHPDoc function is not documented", $ptr, 'phpdoc_fun_doc_missing' ); @@ -1004,13 +1015,13 @@ protected function processFunction(object $scope, ?object $comment): void { ); if (!$docparamparsed->type) { $this->file->addError( - 'PHPDoc function parameter type missing or malformed', + "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', + "PHPDoc function parameter name missing or malformed", $docparam->ptr, 'phpdoc_fun_param_name' ); @@ -1028,7 +1039,7 @@ protected function processFunction(object $scope, ?object $comment): void { if (isset($docparamsmatched[$docparamparsed->name])) { $this->file->addError( - 'PHPDoc function parameter repeated', + "PHPDoc function parameter repeated", $docparam->ptr, 'phpdoc_fun_param_type_repeat' ); @@ -1037,11 +1048,21 @@ protected function processFunction(object $scope, ?object $comment): void { if (!$this->typeparser->comparetypes($paramparsed->type, $docparamparsed->type)) { $this->file->addError( - 'PHPDoc function parameter type mismatch', + "PHPDoc function parameter type mismatch", $docparam->ptr, 'phpdoc_fun_param_type_mismatch' ); - } elseif ($docparamparsed->fixed) { + } + + 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, @@ -1054,9 +1075,10 @@ protected function processFunction(object $scope, ?object $comment): void { ); } } + if ($paramparsed->passsplat != $docparamparsed->passsplat) { $this->file->addWarning( - 'PHPDoc function parameter splat mismatch', + "PHPDoc function parameter splat mismatch", $docparam->ptr, 'phpdoc_fun_param_pass_splat_mismatch' ); @@ -1065,7 +1087,7 @@ protected function processFunction(object $scope, ?object $comment): void { } // Check all parameters are documented (if all documented parameters were recognised). - if (CHECK_HAS_DOCS && count($docparamsmatched) == count($comment->tags['@param'])) { + if (static::CHECK_HAS_DOCS && count($docparamsmatched) == count($comment->tags['@param'])) { foreach ($paramparsedarray as $paramname => $paramparsed) { if (!isset($docparamsmatched[$paramname])) { $this->file->addWarning( @@ -1112,18 +1134,18 @@ protected function processFunction(object $scope, ?object $comment): void { $comment->tags['@return'] = []; } if ( - CHECK_HAS_DOCS && count($comment->tags['@return']) < 1 + 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', + "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 |', + "PHPDoc multiple function @return tags--Put in one tag, seperated by vertical bars |", $comment->tags['@return'][1]->ptr, 'phpdoc_fun_ret_multiple' ); @@ -1137,30 +1159,43 @@ protected function processFunction(object $scope, ?object $comment): void { 0/*type only*/, false/*phpdoc*/ ); + if (!$docretparsed->type) { $this->file->addError( - 'PHPDoc function return type missing or malformed', + "PHPDoc function return type missing or malformed", $docret->ptr, 'phpdoc_fun_ret_type' ); - } elseif (!$this->typeparser->comparetypes($retparsed->type, $docretparsed->type)) { - $this->file->addError( - 'PHPDoc function return type mismatch', - $docret->ptr, - 'phpdoc_fun_ret_type_mismatch' - ); - } elseif ($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 + } 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 + ); + } + } } } } @@ -1198,12 +1233,29 @@ 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'); + $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'); + $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", @@ -1274,10 +1326,10 @@ protected function processVariable(object $scope, ?object $comment): void { : null; $vartype = ($properties && $properties['type']) ? $properties['type'] : 'mixed'; - if (CHECK_HAS_DOCS && !$comment && $scope->type == 'classish') { + 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', + "PHPDoc variable or constant is not documented", $this->fileptr, 'phpdoc_var_doc_missing' ); @@ -1293,17 +1345,23 @@ protected function processVariable(object $scope, ?object $comment): void { } // Missing var tag. - if (CHECK_HAS_DOCS && count($comment->tags['@var']) < 1) { - $this->file->addWarning('PHPDoc variable missing @var tag', $comment->ptr, 'phpdoc_var_missing'); + 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, @@ -1311,30 +1369,43 @@ protected function processVariable(object $scope, ?object $comment): void { 0/*type only*/, false/*phpdoc*/ ); + if (!$docvarparsed->type) { $this->file->addError( - 'PHPDoc var type missing or malformed', + "PHPDoc var type missing or malformed", $docvar->ptr, 'phpdoc_var_type' ); - } elseif (!$this->typeparser->comparetypes($varparsed->type, $docvarparsed->type)) { - $this->file->addError( - 'PHPDoc var type mismatch', - $docvar->ptr, - 'phpdoc_var_type_mismatch' - ); - } elseif ($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 + } 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 + ); + } + } } } } @@ -1383,23 +1454,34 @@ protected function processPossVarComment(?object $scope, ?object $comment): void 0/*type only*/, false/*phpdoc*/ ); + if (!$docvarparsed->type) { $this->file->addError( - 'PHPDoc var type missing or malformed', + "PHPDoc var type missing or malformed", $docvar->ptr, 'phpdoc_var_type' ); - } elseif ($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 + } 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 index 759a76c..0a59a9c 100644 --- a/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php +++ b/moodle/Tests/Sniffs/Commenting/PHPDocTypesSniffTest.php @@ -62,8 +62,20 @@ public function testPHPDocTypesCorrectness( */ public static function provider(): array { return [ - /*'PHPDocTypes docs missing wrong' => [ - 'fixture' => 'phpdoctypes/phpdoctypes_docs_missing_wrong', + '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", @@ -102,11 +114,6 @@ public static function provider(): array { 54 => "PHPDoc function parameter splat mismatch", ], ], - 'PHPDocTypes method union types right' => [ - 'fixture' => 'phpdoctypes/phpdoctypes_method_union_types_right', - 'errors' => [], - 'warnings' => [], - ], 'PHPDocTypes namespace right' => [ 'fixture' => 'phpdoctypes/phpdoctypes_namespace_right', 'errors' => [], @@ -119,8 +126,8 @@ public static function provider(): array { ], 'warnings' => [], ], - 'PHPDocTypes style wrong' => [ - 'fixture' => 'phpdoctypes/phpdoctypes_style_wrong', + 'PHPDocTypes style warn' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_style_warn', 'errors' => [], 'warnings' => [ 36 => "PHPDoc class property type doesn't conform to recommended style", @@ -133,11 +140,6 @@ public static function provider(): array { 63 => "PHPDoc var type doesn't conform to recommended style", ], ], - 'PHPDocTypes tags general right' => [ - 'fixture' => 'phpdoctypes/phpdoctypes_tags_general_right', - 'errors' => [], - 'warnings' => [], - ], ]; } } 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_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_warn.php similarity index 100% rename from moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_wrong.php rename to moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_docs_missing_warn.php diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php index 2ed78bd..818c7c0 100644 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_general_right.php @@ -58,14 +58,13 @@ trait php_valid_trait { * @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 */ class php_valid extends php_valid_parent implements php_valid_interface { use php_valid_trait; /** * Namespaces recognised - * @param \MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures\php_valid $x + * @param \MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures\php_valid $x * @return void */ function namespaces(php_valid $x): void { @@ -81,7 +80,7 @@ function uses(myStdClass $x): void { /** * Parents recognised - * @param php_valid $x + * @param php_valid $x * @return void */ function parents(php_valid_parent $x): void { @@ -89,20 +88,12 @@ function parents(php_valid_parent $x): void { /** * Interfaces recognised - * @param php_valid $x + * @param php_valid $x * @return void */ function interfaces(php_valid_interface $x): void { } - /** - * Class templates recognised - * @param T $x - * @return void - */ - function class_templates(int $x): void { - } - /** * Function templates recognised * @template T2 of int @@ -120,16 +111,25 @@ function function_templates(int $x): void { public function visibility(int $x): void { } + /** @var int + * | bool Multiline type */ + public int|bool $v2; + +} + +/** + * @template T of int + */ +class php_valid_2 { + /** - * Multiline comment - * @param object{ - * a: int, - * b: string - * } $x + * Class templates recognised + * @param T $x * @return void */ - function multiline_comment(object $x): void { + function class_templates(int $x): void { } + } // Ignore things that don't concern us. diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_method_union_types_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_method_union_types_right.php deleted file mode 100644 index 61fbb7f..0000000 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_method_union_types_right.php +++ /dev/null @@ -1,92 +0,0 @@ -. - -namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\Commenting\fixtures; - -/** - * A fixture to verify various phpdoc tags in a general location. - * - * @package local_moodlecheck - * @copyright 2023 Andrew Lyons - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class union_types { - /** - * An example of a method on a single line using union types in both the params and return values - * @param string|int $value - * @return string|int - */ - public function method_oneline(string|int $value): string|int { - // Do something. - return $value; - } - - /** - * An example of a method on a single line using union types in both the params and return values - * - * @param string|int $value - * @param int|float $othervalue - * @return string|int - */ - public function method_oneline_multi(string|int $value, int|float $othervalue): string|int { - // Do something. - return $value; - } - - /** - * An example of a method on a single line using union types in both the params and return values - * - * @param string|int $value - * @param int|float $othervalue - * @return string|int - */ - public function method_multiline( - string|int $value, - int|float $othervalue, - ): string|int { - // Do something. - return $value; - } - - /** - * An example of a method whose union values are not in the same order. - - * @param int|string $value - * @param int|float $othervalue - * @return int|string - */ - public function method_union_order_does_not_matter( - string|int $value, - float|int $othervalue, - ): string|int { - // Do something. - return $value; - } - - /** - * An example of a method which uses strings, or an array of strings. - * - * @param string|string[] $arrayofstrings - * @return string[]|string - */ - public function method_union_containing_array( - string|array $arrayofstrings, - ): string|array { - return [ - 'example', - ]; - } -} diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php similarity index 93% rename from moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php rename to moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php index 920accd..a8ea33b 100644 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php @@ -38,11 +38,11 @@ class types_invalid { /** - * @param Integer $p PHPDoc function parameter type doesn't conform to recommended style + * @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(int $p): int { + public function fun_wrong($p): int { return 0; } diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php.fixed b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php.fixed similarity index 93% rename from moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php.fixed rename to moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php.fixed index 70ceeb4..146c1ad 100644 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_wrong.php.fixed +++ b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_style_warn.php.fixed @@ -38,11 +38,11 @@ class types_invalid { /** - * @param int $p PHPDoc function parameter type doesn't conform to recommended style + * @param bool|T $p PHPDoc function parameter type doesn't conform to recommended style * @return int PHPDoc function return type doesn't conform to recommended style * @template T of int PHPDoc tempate type doesn't conform to recommended style */ - public function fun_wrong(int $p): int { + public function fun_wrong($p): int { return 0; } diff --git a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_tags_general_right.php b/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_tags_general_right.php deleted file mode 100644 index 7b97d5c..0000000 --- a/moodle/Tests/Sniffs/Commenting/fixtures/phpdoctypes/phpdoctypes_tags_general_right.php +++ /dev/null @@ -1,142 +0,0 @@ -. - -/** - * A fixture to verify various phpdoc tags in a general location. - * - * @package local_moodlecheck - * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -global $CFG; - -/** - * A fixture to verify various phpdoc tags in a general location. - * - * @package local_moodlecheck - * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class fixturing_general { - - /** - * Correct param types. - * - * @param string|bool $one - * @param bool $two - * @param array $three - * @return void - */ - public function correct_param_types($one, bool $two, array $three): void { - echo "yay!"; - } - - /** - * Correct param types. - * - * @param string|bool $one - * @param bool $two - * @param array $three - * @return void - */ - public function correct_param_types1($one, bool $two, array $three): void { - echo "yay!"; - } - - /** - * Correct param types. - * - * @param string $one - * @param bool $two - * @return void - */ - public function correct_param_types2($one, $two): void { - echo "yay!"; - } - - /** - * Correct param types. - * - * @param string|null $one - * @param bool $two - * @param array $three - * @return void - */ - public function correct_param_types3(?string $one, bool $two, array $three): void { - echo "yay!"; - } - - /** - * Correct param types. - * - * @param string $one - * @param bool $two - * @param int[]|null $three - * @return void - */ - public function correct_param_types4($one, bool $two, array $three = null): void { - echo "yay!"; - } - - /** - * Correct param types. - * - * @param string $one - * @param mixed ...$params one or more params - * @return void - */ - public function correct_param_types5(string $one, ...$params): void { - echo "yay!"; - } - - /** - * Correct return type. - * - * @return string - */ - public function correct_return_type(): string { - return "yay!"; - } - - /** - * Namespaced types. - * - * @param \stdClass $data - * @param \core\user $user - * @return \core\user - */ - public function namespaced_parameter_type( - \stdClass $data, - \core\user $user - ): \core\user { - return $user; - } - - /** - * Namespaced types. - * - * @param null|\stdClass $data - * @param null|\core\test\something|\core\some\other_thing $moredata - * @return \stdClass - */ - public function builtin( - ?\stdClass $data, - \core\test\something|\core\some\other_thing|null $moredata - ): \stdClass { - return new stdClass(); - } -} diff --git a/moodle/Tests/Util/PHPDocTypeParserTest.php b/moodle/Tests/Util/PHPDocTypeParserTest.php index bbbfb2b..6f8498a 100644 --- a/moodle/Tests/Util/PHPDocTypeParserTest.php +++ b/moodle/Tests/Util/PHPDocTypeParserTest.php @@ -62,13 +62,85 @@ public function testPHPDocTypesParser( */ public static function provider(): array { return [ - 'PHPDocTypes all types right' => [ - 'fixture' => 'phpdoctypes/phpdoctypes_all_types_right', + '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' => [ @@ -101,6 +173,11 @@ public static function provider(): array { ], 'warnings' => [], ], + 'PHPDocTypes simple right' => [ + 'fixture' => 'phpdoctypes/phpdoctypes_simple_right', + 'errors' => [], + 'warnings' => [], + ], ]; } } diff --git a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_complex_warn.php similarity index 99% rename from moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php rename to moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_complex_warn.php index 737c6ab..b0b2798 100644 --- a/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_all_types_right.php +++ b/moodle/Tests/Util/fixtures/phpdoctypes/phpdoctypes_complex_warn.php @@ -237,13 +237,13 @@ public function never_type() { } /** - * Void type + * Null type * @param null $standalonenull * @param ?int $explicitnullable * @param ?int $implicitnullable * @return void */ - public function void_type( + public function null_type( $standalonenull, ?int $explicitnullable, int $implicitnullable=null 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 index bac8bfc..348a862 100644 --- a/moodle/Util/PHPDocTypeParser.php +++ b/moodle/Util/PHPDocTypeParser.php @@ -147,6 +147,9 @@ class PHPDocTypeParser /** @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 = []; @@ -168,8 +171,8 @@ public function __construct(?array $artifacts = null) { * @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} - * the simplified type, pass by reference & splat, variable name, remaining text, and fixed text + * 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 { @@ -182,6 +185,7 @@ public function parseTypeAndName(?object $scope, string $text, int $getwhat, boo $this->text = $text; $this->replacements = []; $this->gowide = $gowide; + $this->complex = false; $this->nexts = []; $this->next = $this->next(); @@ -258,15 +262,15 @@ public function parseTypeAndName(?object $scope, string $text, int $getwhat, boo return (object)['type' => $type, 'passsplat' => $passsplat, 'name' => $name, 'rem' => trim(substr($text, $this->nexts[0]->startpos)), - 'fixed' => $type ? $this->getFixed() : null]; + '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} - * the simplified type, template name, remaining text, and fixed text + * @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 { @@ -279,6 +283,7 @@ public function parseTemplate(?object $scope, string $text): object { $this->text = $text; $this->replacements = []; $this->gowide = false; + $this->complex = false; $this->nexts = []; $this->next = $this->next(); @@ -328,7 +333,7 @@ public function parseTemplate(?object $scope, string $text): object { return (object)['type' => $type, 'name' => $name, 'rem' => trim(substr($text, $this->nexts[0]->startpos)), - 'fixed' => $type ? $this->getFixed() : null]; + 'fixed' => $type ? $this->getFixed() : null, 'complex' => $this->complex]; } /** @@ -474,7 +479,10 @@ protected function next(int $lookahead = 0): ?string { if ($firstchar == null) { // No more tokens. $endpos = $startpos; - } elseif (ctype_alpha($firstchar) || $firstchar == '_' || $firstchar == '$' || $firstchar == "\\") { + } elseif ( + ctype_alpha($firstchar) || $firstchar == '_' || $firstchar == '$' || $firstchar == "\\" + || ord($firstchar) >= 0x7F && ord($firstchar) <= 0xFF + ) { // Identifier token. $endpos = $startpos; do { @@ -482,6 +490,7 @@ protected function next(int $lookahead = 0): ?string { $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 ( @@ -605,6 +614,7 @@ 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(); @@ -615,6 +625,7 @@ protected function parseAnyType(bool $inbrackets = false): string { $uniontypes = array_merge(explode('|', $firsttype), explode('|', $secondtype)); } elseif ($this->next == '?') { // Single nullable type. + $this->complex = true; $this->parseToken('?'); $uniontypes = explode('|', $this->parseSingleType()); $uniontypes[] = 'null'; @@ -770,10 +781,14 @@ protected function parseBasicType(): string { || (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 ( @@ -824,7 +839,10 @@ protected function parseBasicType(): string { || (ctype_digit($nextchar) || $nextchar == '-') && strpos($next, '.') !== false ) { // Float. - $this->correctToken($lowernext); + if (!in_array($lowernext, ['float', 'double'])) { + $this->complex = true; + } + $this->correctToken(($lowernext == 'double') ? 'float' : $lowernext); $this->parseToken(); $type = 'float'; } elseif ( @@ -833,6 +851,9 @@ protected function parseBasicType(): string { || $nextchar == '"' || $nextchar == "'" ) { // String. + if ($lowernext != 'string') { + $this->complex = true; + } if ($nextchar != '"' && $nextchar != "'") { $this->correctToken($lowernext); } @@ -848,15 +869,20 @@ protected function parseBasicType(): string { $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 == ',') { @@ -876,6 +902,7 @@ protected function parseBasicType(): string { $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."); } @@ -909,6 +936,7 @@ protected function parseBasicType(): string { $this->parseToken('object'); if ($this->next == '{') { // Object shape. + $this->complex = true; $this->parseToken('{'); do { $next = $this->next; @@ -959,6 +987,7 @@ protected function parseBasicType(): string { $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'; @@ -977,6 +1006,7 @@ protected function parseBasicType(): string { } $callabletype = $this->parseToken(); if ($this->next == '(') { + $this->complex = true; $this->parseToken('('); while ($this->next != ')') { $this->parseAnyType(); @@ -1020,6 +1050,7 @@ protected function parseBasicType(): string { $this->correctToken($lowernext); $this->parseToken('iterable'); if ($this->next == '<') { + $this->complex = true; $this->parseToken('<'); $firsttype = $this->parseAnyType(); if ($this->next == ',') { @@ -1035,16 +1066,19 @@ protected function parseBasicType(): string { $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('<'); @@ -1056,6 +1090,7 @@ protected function parseBasicType(): string { $type = $this->gowide ? 'mixed' : 'never'; } elseif ($lowernext == 'value-of') { // Value-of. + $this->complex = true; $this->correctToken($lowernext); $this->parseToken('value-of'); $this->parseToken('<'); @@ -1091,6 +1126,7 @@ protected function parseBasicType(): string { // 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 { @@ -1103,6 +1139,7 @@ protected function parseBasicType(): string { $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 == '_');