diff --git a/.github/workflows/apiref.yml b/.github/workflows/apiref.yml index a315a699..f9384b3f 100644 --- a/.github/workflows/apiref.yml +++ b/.github/workflows/apiref.yml @@ -5,7 +5,7 @@ name: "Deploy API Reference" on: push: branches: - - "1.23.x" + - "2.0.x" concurrency: group: "pages" @@ -36,7 +36,7 @@ jobs: run: "composer install --no-interaction --no-progress" - name: "Run ApiGen" - run: "apigen/vendor/bin/apigen -c apigen/apigen.neon --output docs -- src" + run: "apigen/vendor/bin/apigen -c apigen/apigen.neon --output docs/${{ github.ref_name }} -- src" - name: "Copy favicon" run: "cp apigen/favicon.png docs/favicon.png" diff --git a/.github/workflows/backward-compatibility.yml b/.github/workflows/backward-compatibility.yml index 213da72c..ba1e3130 100644 --- a/.github/workflows/backward-compatibility.yml +++ b/.github/workflows/backward-compatibility.yml @@ -25,14 +25,11 @@ jobs: uses: "shivammathur/setup-php@v2" with: coverage: "none" - php-version: "7.4" + php-version: "8.3" - name: "Install dependencies" run: "composer install --no-dev --no-interaction --no-progress --no-suggest" - - name: "allow composer plugins" - run: "composer config --no-plugins --global allow-plugins.ocramius/package-versions true" - - name: "Install BackwardCompatibilityCheck" run: "composer global require --dev roave/backward-compatibility-check" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d356f34e..01b49f52 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,8 @@ jobs: - "8.0" - "8.1" - "8.2" + - "8.3" + - "8.4" steps: - name: "Checkout" @@ -60,6 +62,7 @@ jobs: with: repository: "phpstan/build-cs" path: "build-cs" + ref: "1.x" - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -98,6 +101,8 @@ jobs: - "8.0" - "8.1" - "8.2" + - "8.3" + - "8.4" dependencies: - "lowest" - "highest" @@ -141,6 +146,8 @@ jobs: - "8.0" - "8.1" - "8.2" + - "8.3" + - "8.4" steps: - name: "Checkout" diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml index c2b017b9..69545301 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -2,7 +2,7 @@ name: 'Lock Issues' on: schedule: - - cron: '0 0 * * *' + - cron: '8 0 * * *' jobs: lock: diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml index 6a1c8156..1ba4fd77 100644 --- a/.github/workflows/release-toot.yml +++ b/.github/workflows/release-toot.yml @@ -10,7 +10,7 @@ jobs: toot: runs-on: ubuntu-latest steps: - - uses: cbrgm/mastodon-github-action@v1 + - uses: cbrgm/mastodon-github-action@v2 if: ${{ !github.event.repository.private }} with: # GitHub event payload diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fb750a4..b1a669a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Generate changelog id: changelog - uses: metcalfc/changelog-generator@v4.2.0 + uses: metcalfc/changelog-generator@v4.3.1 with: myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }} diff --git a/.github/workflows/send-pr.yml b/.github/workflows/send-pr.yml index 023293c7..ab26eb3e 100644 --- a/.github/workflows/send-pr.yml +++ b/.github/workflows/send-pr.yml @@ -23,7 +23,7 @@ jobs: repository: phpstan/phpstan-src path: phpstan-src token: ${{ secrets.PHPSTAN_BOT_TOKEN }} - ref: 1.10.x + ref: 1.12.x - name: "Install dependencies" working-directory: ./phpstan-src @@ -35,7 +35,7 @@ jobs: - name: "Create Pull Request" id: create-pr - uses: peter-evans/create-pull-request@v5 + uses: peter-evans/create-pull-request@v6 with: token: ${{ secrets.PHPSTAN_BOT_TOKEN }} path: ./phpstan-src diff --git a/.github/workflows/test-slevomat-coding-standard.yml b/.github/workflows/test-slevomat-coding-standard.yml index 1967df97..238fed25 100644 --- a/.github/workflows/test-slevomat-coding-standard.yml +++ b/.github/workflows/test-slevomat-coding-standard.yml @@ -22,6 +22,8 @@ jobs: - "8.0" - "8.1" - "8.2" + - "8.3" + - "8.4" steps: - name: "Checkout" diff --git a/Makefile b/Makefile index 8289111c..4399bed1 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ lint: .PHONY: cs-install cs-install: git clone https://github.com/phpstan/build-cs.git || true - git -C build-cs fetch origin && git -C build-cs reset --hard origin/main + git -C build-cs fetch origin && git -C build-cs reset --hard origin/1.x composer install --working-dir build-cs .PHONY: cs diff --git a/README.md b/README.md index 3b321b22..706b2a3c 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ For the complete list of supported PHPDoc features check out PHPStan documentati * [PHPDoc Basics](https://phpstan.org/writing-php-code/phpdocs-basics) (list of PHPDoc tags) * [PHPDoc Types](https://phpstan.org/writing-php-code/phpdoc-types) (list of PHPDoc types) -* [phpdoc-parser API Reference](https://phpstan.github.io/phpdoc-parser/namespace-PHPStan.PhpDocParser.html) with all the AST node types etc. +* [phpdoc-parser API Reference](https://phpstan.github.io/phpdoc-parser/1.23.x/namespace-PHPStan.PhpDocParser.html) with all the AST node types etc. -This parser also supports parsing [Doctrine Annotations](https://github.com/doctrine/annotations). The AST nodes live in the [PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine namespace](https://phpstan.github.io/phpdoc-parser/namespace-PHPStan.PhpDocParser.Ast.PhpDoc.Doctrine.html). The support needs to be turned on by setting `bool $parseDoctrineAnnotations` to `true` in `Lexer` and `PhpDocParser` class constructors. +This parser also supports parsing [Doctrine Annotations](https://github.com/doctrine/annotations). The AST nodes live in the [PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine namespace](https://phpstan.github.io/phpdoc-parser/1.23.x/namespace-PHPStan.PhpDocParser.Ast.PhpDoc.Doctrine.html). The support needs to be turned on by setting `bool $parseDoctrineAnnotations` to `true` in `Lexer` and `PhpDocParser` class constructors. ## Installation diff --git a/doc/grammars/type.abnf b/doc/grammars/type.abnf index 0b3247ba..ad955a9b 100644 --- a/doc/grammars/type.abnf +++ b/doc/grammars/type.abnf @@ -35,7 +35,13 @@ GenericTypeArgument / TokenWildcard Callable - = TokenParenthesesOpen [CallableParameters] TokenParenthesesClose TokenColon CallableReturnType + = [CallableTemplate] TokenParenthesesOpen [CallableParameters] TokenParenthesesClose TokenColon CallableReturnType + +CallableTemplate + = TokenAngleBracketOpen CallableTemplateArgument *(TokenComma CallableTemplateArgument) TokenAngleBracketClose + +CallableTemplateArgument + = TokenIdentifier [1*ByteHorizontalWs TokenOf Type] [1*ByteHorizontalWs TokenSuper Type] ["=" Type] CallableParameters = CallableParameter *(TokenComma CallableParameter) @@ -192,6 +198,12 @@ TokenIs TokenNot = %s"not" 1*ByteHorizontalWs +TokenOf + = %s"of" 1*ByteHorizontalWs + +TokenSuper + = %s"super" 1*ByteHorizontalWs + TokenContravariant = %s"contravariant" 1*ByteHorizontalWs @@ -211,7 +223,7 @@ TokenIdentifier ByteHorizontalWs = %x09 ; horizontal tab - / %x20 ; space + / " " ByteNumberSign = "+" @@ -238,11 +250,8 @@ ByteIdentifierFirst / %x80-FF ByteIdentifierSecond - = %x30-39 ; 0-9 - / %x41-5A ; A-Z - / "_" - / %x61-7A ; a-z - / %x80-FF + = ByteIdentifierFirst + / %x30-39 ; 0-9 ByteSingleQuote = %x27 ; ' diff --git a/src/Ast/PhpDoc/ParamClosureThisTagValueNode.php b/src/Ast/PhpDoc/ParamClosureThisTagValueNode.php new file mode 100644 index 00000000..0ac2131a --- /dev/null +++ b/src/Ast/PhpDoc/ParamClosureThisTagValueNode.php @@ -0,0 +1,35 @@ +type = $type; + $this->parameterName = $parameterName; + $this->description = $description; + } + + public function __toString(): string + { + return trim("{$this->type} {$this->parameterName} {$this->description}"); + } + +} diff --git a/src/Ast/PhpDoc/ParamImmediatelyInvokedCallableTagValueNode.php b/src/Ast/PhpDoc/ParamImmediatelyInvokedCallableTagValueNode.php new file mode 100644 index 00000000..0f480f7a --- /dev/null +++ b/src/Ast/PhpDoc/ParamImmediatelyInvokedCallableTagValueNode.php @@ -0,0 +1,30 @@ +parameterName = $parameterName; + $this->description = $description; + } + + public function __toString(): string + { + return trim("{$this->parameterName} {$this->description}"); + } + +} diff --git a/src/Ast/PhpDoc/ParamLaterInvokedCallableTagValueNode.php b/src/Ast/PhpDoc/ParamLaterInvokedCallableTagValueNode.php new file mode 100644 index 00000000..eab353f9 --- /dev/null +++ b/src/Ast/PhpDoc/ParamLaterInvokedCallableTagValueNode.php @@ -0,0 +1,30 @@ +parameterName = $parameterName; + $this->description = $description; + } + + public function __toString(): string + { + return trim("{$this->parameterName} {$this->description}"); + } + +} diff --git a/src/Ast/PhpDoc/PhpDocNode.php b/src/Ast/PhpDoc/PhpDocNode.php index 4c509f41..a08b777f 100644 --- a/src/Ast/PhpDoc/PhpDocNode.php +++ b/src/Ast/PhpDoc/PhpDocNode.php @@ -90,6 +90,60 @@ static function (PhpDocTagValueNode $value): bool { } + /** + * @return ParamImmediatelyInvokedCallableTagValueNode[] + */ + public function getParamImmediatelyInvokedCallableTagValues(string $tagName = '@param-immediately-invoked-callable'): array + { + return array_filter( + array_column($this->getTagsByName($tagName), 'value'), + static function (PhpDocTagValueNode $value): bool { + return $value instanceof ParamImmediatelyInvokedCallableTagValueNode; + } + ); + } + + + /** + * @return ParamLaterInvokedCallableTagValueNode[] + */ + public function getParamLaterInvokedCallableTagValues(string $tagName = '@param-later-invoked-callable'): array + { + return array_filter( + array_column($this->getTagsByName($tagName), 'value'), + static function (PhpDocTagValueNode $value): bool { + return $value instanceof ParamLaterInvokedCallableTagValueNode; + } + ); + } + + + /** + * @return ParamClosureThisTagValueNode[] + */ + public function getParamClosureThisTagValues(string $tagName = '@param-closure-this'): array + { + return array_filter( + array_column($this->getTagsByName($tagName), 'value'), + static function (PhpDocTagValueNode $value): bool { + return $value instanceof ParamClosureThisTagValueNode; + } + ); + } + + /** + * @return PureUnlessCallableIsImpureTagValueNode[] + */ + public function getPureUnlessCallableIsImpureTagValues(string $tagName = '@pure-unless-callable-is-impure'): array + { + return array_filter( + array_column($this->getTagsByName($tagName), 'value'), + static function (PhpDocTagValueNode $value): bool { + return $value instanceof PureUnlessCallableIsImpureTagValueNode; + } + ); + } + /** * @return TemplateTagValueNode[] */ diff --git a/src/Ast/PhpDoc/PureUnlessCallableIsImpureTagValueNode.php b/src/Ast/PhpDoc/PureUnlessCallableIsImpureTagValueNode.php new file mode 100644 index 00000000..4cf0937d --- /dev/null +++ b/src/Ast/PhpDoc/PureUnlessCallableIsImpureTagValueNode.php @@ -0,0 +1,30 @@ +parameterName = $parameterName; + $this->description = $description; + } + + public function __toString(): string + { + return trim("{$this->parameterName} {$this->description}"); + } + +} diff --git a/src/Ast/PhpDoc/TemplateTagValueNode.php b/src/Ast/PhpDoc/TemplateTagValueNode.php index 1d3c70e4..8bc01f6e 100644 --- a/src/Ast/PhpDoc/TemplateTagValueNode.php +++ b/src/Ast/PhpDoc/TemplateTagValueNode.php @@ -11,22 +11,29 @@ class TemplateTagValueNode implements PhpDocTagValueNode use NodeAttributes; - /** @var string */ + /** @var non-empty-string */ public $name; /** @var TypeNode|null */ public $bound; + /** @var TypeNode|null */ + public $lowerBound; + /** @var TypeNode|null */ public $default; /** @var string (may be empty) */ public $description; - public function __construct(string $name, ?TypeNode $bound, string $description, ?TypeNode $default = null) + /** + * @param non-empty-string $name + */ + public function __construct(string $name, ?TypeNode $bound, string $description, ?TypeNode $default = null, ?TypeNode $lowerBound = null) { $this->name = $name; $this->bound = $bound; + $this->lowerBound = $lowerBound; $this->default = $default; $this->description = $description; } @@ -34,9 +41,10 @@ public function __construct(string $name, ?TypeNode $bound, string $description, public function __toString(): string { - $bound = $this->bound !== null ? " of {$this->bound}" : ''; + $upperBound = $this->bound !== null ? " of {$this->bound}" : ''; + $lowerBound = $this->lowerBound !== null ? " super {$this->lowerBound}" : ''; $default = $this->default !== null ? " = {$this->default}" : ''; - return trim("{$this->name}{$bound}{$default} {$this->description}"); + return trim("{$this->name}{$upperBound}{$lowerBound}{$default} {$this->description}"); } } diff --git a/src/Ast/Type/ArrayShapeNode.php b/src/Ast/Type/ArrayShapeNode.php index 806783f9..73d162de 100644 --- a/src/Ast/Type/ArrayShapeNode.php +++ b/src/Ast/Type/ArrayShapeNode.php @@ -10,6 +10,8 @@ class ArrayShapeNode implements TypeNode public const KIND_ARRAY = 'array'; public const KIND_LIST = 'list'; + public const KIND_NON_EMPTY_ARRAY = 'non-empty-array'; + public const KIND_NON_EMPTY_LIST = 'non-empty-list'; use NodeAttributes; @@ -22,15 +24,24 @@ class ArrayShapeNode implements TypeNode /** @var self::KIND_* */ public $kind; + /** @var ArrayShapeUnsealedTypeNode|null */ + public $unsealedType; + /** * @param ArrayShapeItemNode[] $items * @param self::KIND_* $kind */ - public function __construct(array $items, bool $sealed = true, string $kind = self::KIND_ARRAY) + public function __construct( + array $items, + bool $sealed = true, + string $kind = self::KIND_ARRAY, + ?ArrayShapeUnsealedTypeNode $unsealedType = null + ) { $this->items = $items; $this->sealed = $sealed; $this->kind = $kind; + $this->unsealedType = $unsealedType; } @@ -39,7 +50,7 @@ public function __toString(): string $items = $this->items; if (! $this->sealed) { - $items[] = '...'; + $items[] = '...' . $this->unsealedType; } return $this->kind . '{' . implode(', ', $items) . '}'; diff --git a/src/Ast/Type/ArrayShapeUnsealedTypeNode.php b/src/Ast/Type/ArrayShapeUnsealedTypeNode.php new file mode 100644 index 00000000..7ffdf1d2 --- /dev/null +++ b/src/Ast/Type/ArrayShapeUnsealedTypeNode.php @@ -0,0 +1,34 @@ +valueType = $valueType; + $this->keyType = $keyType; + } + + public function __toString(): string + { + if ($this->keyType !== null) { + return sprintf('<%s, %s>', $this->keyType, $this->valueType); + } + return sprintf('<%s>', $this->valueType); + } + +} diff --git a/src/Ast/Type/CallableTypeNode.php b/src/Ast/Type/CallableTypeNode.php index e57e5f82..4c913198 100644 --- a/src/Ast/Type/CallableTypeNode.php +++ b/src/Ast/Type/CallableTypeNode.php @@ -3,6 +3,7 @@ namespace PHPStan\PhpDocParser\Ast\Type; use PHPStan\PhpDocParser\Ast\NodeAttributes; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use function implode; class CallableTypeNode implements TypeNode @@ -13,6 +14,9 @@ class CallableTypeNode implements TypeNode /** @var IdentifierTypeNode */ public $identifier; + /** @var TemplateTagValueNode[] */ + public $templateTypes; + /** @var CallableTypeParameterNode[] */ public $parameters; @@ -21,12 +25,14 @@ class CallableTypeNode implements TypeNode /** * @param CallableTypeParameterNode[] $parameters + * @param TemplateTagValueNode[] $templateTypes */ - public function __construct(IdentifierTypeNode $identifier, array $parameters, TypeNode $returnType) + public function __construct(IdentifierTypeNode $identifier, array $parameters, TypeNode $returnType, array $templateTypes = []) { $this->identifier = $identifier; $this->parameters = $parameters; $this->returnType = $returnType; + $this->templateTypes = $templateTypes; } @@ -36,8 +42,11 @@ public function __toString(): string if ($returnType instanceof self) { $returnType = "({$returnType})"; } + $template = $this->templateTypes !== [] + ? '<' . implode(', ', $this->templateTypes) . '>' + : ''; $parameters = implode(', ', $this->parameters); - return "{$this->identifier}({$parameters}): {$returnType}"; + return "{$this->identifier}{$template}({$parameters}): {$returnType}"; } } diff --git a/src/Ast/Type/OffsetAccessTypeNode.php b/src/Ast/Type/OffsetAccessTypeNode.php index 39e83dfe..c27ec0a3 100644 --- a/src/Ast/Type/OffsetAccessTypeNode.php +++ b/src/Ast/Type/OffsetAccessTypeNode.php @@ -25,7 +25,6 @@ public function __toString(): string { if ( $this->type instanceof CallableTypeNode - || $this->type instanceof ConstTypeNode || $this->type instanceof NullableTypeNode ) { return '(' . $this->type . ')[' . $this->offset . ']'; diff --git a/src/Parser/PhpDocParser.php b/src/Parser/PhpDocParser.php index e87d92c4..b6cd85ea 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -237,7 +237,7 @@ private function parseText(TokenIterator $tokens): Ast\PhpDoc\PhpDocTextNode $text .= $tmpText; // stop if we're not at EOL - meaning it's the end of PHPDoc - if (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC)) { break; } @@ -293,7 +293,7 @@ private function parseOptionalDescriptionAfterDoctrineTag(TokenIterator $tokens) $text .= $tmpText; // stop if we're not at EOL - meaning it's the end of PHPDoc - if (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC)) { if (!$tokens->isPrecededByHorizontalWhitespace()) { return trim($text . $this->parseText($tokens)->text, " \t"); } @@ -384,18 +384,42 @@ public function parseTagValue(TokenIterator $tokens, string $tag): Ast\PhpDoc\Ph case '@param': case '@phpstan-param': case '@psalm-param': + case '@phan-param': $tagValue = $this->parseParamTagValue($tokens); break; + case '@param-immediately-invoked-callable': + case '@phpstan-param-immediately-invoked-callable': + $tagValue = $this->parseParamImmediatelyInvokedCallableTagValue($tokens); + break; + + case '@param-later-invoked-callable': + case '@phpstan-param-later-invoked-callable': + $tagValue = $this->parseParamLaterInvokedCallableTagValue($tokens); + break; + + case '@param-closure-this': + case '@phpstan-param-closure-this': + $tagValue = $this->parseParamClosureThisTagValue($tokens); + break; + + case '@pure-unless-callable-is-impure': + case '@phpstan-pure-unless-callable-is-impure': + $tagValue = $this->parsePureUnlessCallableIsImpureTagValue($tokens); + break; + case '@var': case '@phpstan-var': case '@psalm-var': + case '@phan-var': $tagValue = $this->parseVarTagValue($tokens); break; case '@return': case '@phpstan-return': case '@psalm-return': + case '@phan-return': + case '@phan-real-return': $tagValue = $this->parseReturnTagValue($tokens); break; @@ -405,6 +429,7 @@ public function parseTagValue(TokenIterator $tokens, string $tag): Ast\PhpDoc\Ph break; case '@mixin': + case '@phan-mixin': $tagValue = $this->parseMixinTagValue($tokens); break; @@ -431,29 +456,41 @@ public function parseTagValue(TokenIterator $tokens, string $tag): Ast\PhpDoc\Ph case '@psalm-property': case '@psalm-property-read': case '@psalm-property-write': + case '@phan-property': + case '@phan-property-read': + case '@phan-property-write': $tagValue = $this->parsePropertyTagValue($tokens); break; case '@method': case '@phpstan-method': case '@psalm-method': + case '@phan-method': $tagValue = $this->parseMethodTagValue($tokens); break; case '@template': case '@phpstan-template': case '@psalm-template': + case '@phan-template': case '@template-covariant': case '@phpstan-template-covariant': case '@psalm-template-covariant': case '@template-contravariant': case '@phpstan-template-contravariant': case '@psalm-template-contravariant': - $tagValue = $this->parseTemplateTagValue($tokens, true); + $tagValue = $this->typeParser->parseTemplateTagValue( + $tokens, + function ($tokens) { + return $this->parseOptionalDescription($tokens); + } + ); break; case '@extends': case '@phpstan-extends': + case '@phan-extends': + case '@phan-inherits': case '@template-extends': $tagValue = $this->parseExtendsTagValue('@extends', $tokens); break; @@ -472,6 +509,7 @@ public function parseTagValue(TokenIterator $tokens, string $tag): Ast\PhpDoc\Ph case '@phpstan-type': case '@psalm-type': + case '@phan-type': $tagValue = $this->parseTypeAliasTagValue($tokens); break; @@ -486,6 +524,9 @@ public function parseTagValue(TokenIterator $tokens, string $tag): Ast\PhpDoc\Ph case '@psalm-assert': case '@psalm-assert-if-true': case '@psalm-assert-if-false': + case '@phan-assert': + case '@phan-assert-if-true': + case '@phan-assert-if-false': $tagValue = $this->parseAssertTagValue($tokens); break; @@ -856,6 +897,41 @@ private function parseParamTagValue(TokenIterator $tokens): Ast\PhpDoc\PhpDocTag } + private function parseParamImmediatelyInvokedCallableTagValue(TokenIterator $tokens): Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode + { + $parameterName = $this->parseRequiredVariableName($tokens); + $description = $this->parseOptionalDescription($tokens); + + return new Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode($parameterName, $description); + } + + + private function parseParamLaterInvokedCallableTagValue(TokenIterator $tokens): Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode + { + $parameterName = $this->parseRequiredVariableName($tokens); + $description = $this->parseOptionalDescription($tokens); + + return new Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode($parameterName, $description); + } + + + private function parseParamClosureThisTagValue(TokenIterator $tokens): Ast\PhpDoc\ParamClosureThisTagValueNode + { + $type = $this->typeParser->parse($tokens); + $parameterName = $this->parseRequiredVariableName($tokens); + $description = $this->parseOptionalDescription($tokens); + + return new Ast\PhpDoc\ParamClosureThisTagValueNode($type, $parameterName, $description); + } + + private function parsePureUnlessCallableIsImpureTagValue(TokenIterator $tokens): Ast\PhpDoc\PureUnlessCallableIsImpureTagValueNode + { + $parameterName = $this->parseRequiredVariableName($tokens); + $description = $this->parseOptionalDescription($tokens); + + return new Ast\PhpDoc\PureUnlessCallableIsImpureTagValueNode($parameterName, $description); + } + private function parseVarTagValue(TokenIterator $tokens): Ast\PhpDoc\VarTagValueNode { $type = $this->typeParser->parse($tokens); @@ -919,10 +995,16 @@ private function parsePropertyTagValue(TokenIterator $tokens): Ast\PhpDoc\Proper private function parseMethodTagValue(TokenIterator $tokens): Ast\PhpDoc\MethodTagValueNode { - $isStatic = $tokens->tryConsumeTokenValue('static'); - $startLine = $tokens->currentTokenLine(); - $startIndex = $tokens->currentTokenIndex(); - $returnTypeOrMethodName = $this->typeParser->parse($tokens); + $staticKeywordOrReturnTypeOrMethodName = $this->typeParser->parse($tokens); + + if ($staticKeywordOrReturnTypeOrMethodName instanceof Ast\Type\IdentifierTypeNode && $staticKeywordOrReturnTypeOrMethodName->name === 'static') { + $isStatic = true; + $returnTypeOrMethodName = $this->typeParser->parse($tokens); + + } else { + $isStatic = false; + $returnTypeOrMethodName = $staticKeywordOrReturnTypeOrMethodName; + } if ($tokens->isCurrentTokenType(Lexer::TOKEN_IDENTIFIER)) { $returnType = $returnTypeOrMethodName; @@ -930,9 +1012,7 @@ private function parseMethodTagValue(TokenIterator $tokens): Ast\PhpDoc\MethodTa $tokens->next(); } elseif ($returnTypeOrMethodName instanceof Ast\Type\IdentifierTypeNode) { - $returnType = $isStatic - ? $this->typeParser->enrichWithAttributes($tokens, new Ast\Type\IdentifierTypeNode('static'), $startLine, $startIndex) - : null; + $returnType = $isStatic ? $staticKeywordOrReturnTypeOrMethodName : null; $methodName = $returnTypeOrMethodName->name; $isStatic = false; @@ -947,7 +1027,12 @@ private function parseMethodTagValue(TokenIterator $tokens): Ast\PhpDoc\MethodTa do { $startLine = $tokens->currentTokenLine(); $startIndex = $tokens->currentTokenIndex(); - $templateTypes[] = $this->enrichWithAttributes($tokens, $this->parseTemplateTagValue($tokens, false), $startLine, $startIndex); + $templateTypes[] = $this->enrichWithAttributes( + $tokens, + $this->typeParser->parseTemplateTagValue($tokens), + $startLine, + $startIndex + ); } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); } @@ -1003,33 +1088,6 @@ private function parseMethodTagValueParameter(TokenIterator $tokens): Ast\PhpDoc ); } - private function parseTemplateTagValue(TokenIterator $tokens, bool $parseDescription): Ast\PhpDoc\TemplateTagValueNode - { - $name = $tokens->currentTokenValue(); - $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); - - if ($tokens->tryConsumeTokenValue('of') || $tokens->tryConsumeTokenValue('as')) { - $bound = $this->typeParser->parse($tokens); - - } else { - $bound = null; - } - - if ($tokens->tryConsumeTokenValue('=')) { - $default = $this->typeParser->parse($tokens); - } else { - $default = null; - } - - if ($parseDescription) { - $description = $this->parseOptionalDescription($tokens); - } else { - $description = ''; - } - - return new Ast\PhpDoc\TemplateTagValueNode($name, $bound, $description, $default); - } - private function parseExtendsTagValue(string $tagName, TokenIterator $tokens): Ast\PhpDoc\PhpDocTagValueNode { $startLine = $tokens->currentTokenLine(); @@ -1061,7 +1119,7 @@ private function parseTypeAliasTagValue(TokenIterator $tokens): Ast\PhpDoc\TypeA $alias = $tokens->currentTokenValue(); $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); - // support psalm-type syntax + // support phan-type/psalm-type syntax $tokens->tryConsumeTokenType(Lexer::TOKEN_EQUAL); if ($this->preserveTypeAliasesWithInvalidTypes) { diff --git a/src/Parser/StringUnescaper.php b/src/Parser/StringUnescaper.php index 70524055..a3bbeedd 100644 --- a/src/Parser/StringUnescaper.php +++ b/src/Parser/StringUnescaper.php @@ -2,6 +2,7 @@ namespace PHPStan\PhpDocParser\Parser; +use PHPStan\ShouldNotHappenException; use function chr; use function hexdec; use function octdec; @@ -56,6 +57,9 @@ static function ($matches) { return chr((int) hexdec(substr($str, 1))); } if ($str[0] === 'u') { + if (!isset($matches[2])) { + throw new ShouldNotHappenException(); + } return self::codePointToUtf8((int) hexdec($matches[2])); } diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 79e70275..982eba7d 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -4,6 +4,7 @@ use LogicException; use PHPStan\PhpDocParser\Ast; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Lexer\Lexer; use function in_array; use function str_replace; @@ -164,18 +165,28 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode return $type; } - $type = $this->parseGeneric($tokens, $type); + $origType = $type; + $type = $this->tryParseCallable($tokens, $type, true); + if ($type === $origType) { + $type = $this->parseGeneric($tokens, $type); - if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { - $type = $this->tryParseArrayOrOffsetAccess($tokens, $type); + if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { + $type = $this->tryParseArrayOrOffsetAccess($tokens, $type); + } } } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_PARENTHESES)) { - $type = $this->tryParseCallable($tokens, $type); + $type = $this->tryParseCallable($tokens, $type, false); } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { $type = $this->tryParseArrayOrOffsetAccess($tokens, $type); - } elseif (in_array($type->name, ['array', 'list', 'object'], true) && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET) && !$tokens->isPrecededByHorizontalWhitespace()) { + } elseif (in_array($type->name, [ + Ast\Type\ArrayShapeNode::KIND_ARRAY, + Ast\Type\ArrayShapeNode::KIND_LIST, + Ast\Type\ArrayShapeNode::KIND_NON_EMPTY_ARRAY, + Ast\Type\ArrayShapeNode::KIND_NON_EMPTY_LIST, + 'object', + ], true) && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET) && !$tokens->isPrecededByHorizontalWhitespace()) { if ($type->name === 'object') { $type = $this->parseObjectShape($tokens); } else { @@ -227,7 +238,17 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode ); } - return $this->enrichWithAttributes($tokens, new Ast\Type\ConstTypeNode($constExpr), $startLine, $startIndex); + $type = $this->enrichWithAttributes( + $tokens, + new Ast\Type\ConstTypeNode($constExpr), + $startLine, + $startIndex + ); + if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { + $type = $this->tryParseArrayOrOffsetAccess($tokens, $type); + } + + return $type; } catch (LogicException $e) { throw new ParserException( $currentTokenValue, @@ -464,10 +485,55 @@ public function parseGenericTypeArgument(TokenIterator $tokens): array return [$type, $variance]; } + /** + * @throws ParserException + * @param ?callable(TokenIterator): string $parseDescription + */ + public function parseTemplateTagValue( + TokenIterator $tokens, + ?callable $parseDescription = null + ): TemplateTagValueNode + { + $name = $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + + $upperBound = $lowerBound = null; + + if ($tokens->tryConsumeTokenValue('of') || $tokens->tryConsumeTokenValue('as')) { + $upperBound = $this->parse($tokens); + } + + if ($tokens->tryConsumeTokenValue('super')) { + $lowerBound = $this->parse($tokens); + } + + if ($tokens->tryConsumeTokenValue('=')) { + $default = $this->parse($tokens); + } else { + $default = null; + } + + if ($parseDescription !== null) { + $description = $parseDescription($tokens); + } else { + $description = ''; + } + + if ($name === '') { + throw new LogicException('Template tag name cannot be empty.'); + } + + return new Ast\PhpDoc\TemplateTagValueNode($name, $upperBound, $description, $default, $lowerBound); + } + /** @phpstan-impure */ - private function parseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $identifier): Ast\Type\TypeNode + private function parseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $identifier, bool $hasTemplate): Ast\Type\TypeNode { + $templates = $hasTemplate + ? $this->parseCallableTemplates($tokens) + : []; + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_PARENTHESES); $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); @@ -492,7 +558,52 @@ private function parseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNod $startIndex = $tokens->currentTokenIndex(); $returnType = $this->enrichWithAttributes($tokens, $this->parseCallableReturnType($tokens), $startLine, $startIndex); - return new Ast\Type\CallableTypeNode($identifier, $parameters, $returnType); + return new Ast\Type\CallableTypeNode($identifier, $parameters, $returnType, $templates); + } + + + /** + * @return Ast\PhpDoc\TemplateTagValueNode[] + * + * @phpstan-impure + */ + private function parseCallableTemplates(TokenIterator $tokens): array + { + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); + + $templates = []; + + $isFirst = true; + while ($isFirst || $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + // trailing comma case + if (!$isFirst && $tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET)) { + break; + } + $isFirst = false; + + $templates[] = $this->parseCallableTemplateArgument($tokens); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + } + + $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); + + return $templates; + } + + + private function parseCallableTemplateArgument(TokenIterator $tokens): Ast\PhpDoc\TemplateTagValueNode + { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + + return $this->enrichWithAttributes( + $tokens, + $this->parseTemplateTagValue($tokens), + $startLine, + $startIndex + ); } @@ -585,7 +696,13 @@ private function parseCallableReturnType(TokenIterator $tokens): Ast\Type\TypeNo $startIndex )); - } elseif (in_array($type->name, ['array', 'list', 'object'], true) && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET) && !$tokens->isPrecededByHorizontalWhitespace()) { + } elseif (in_array($type->name, [ + Ast\Type\ArrayShapeNode::KIND_ARRAY, + Ast\Type\ArrayShapeNode::KIND_LIST, + Ast\Type\ArrayShapeNode::KIND_NON_EMPTY_ARRAY, + Ast\Type\ArrayShapeNode::KIND_NON_EMPTY_LIST, + 'object', + ], true) && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET) && !$tokens->isPrecededByHorizontalWhitespace()) { if ($type->name === 'object') { $type = $this->parseObjectShape($tokens); } else { @@ -645,14 +762,14 @@ private function parseCallableReturnType(TokenIterator $tokens): Ast\Type\TypeNo ); } - $type = new Ast\Type\ConstTypeNode($constExpr); + $type = $this->enrichWithAttributes( + $tokens, + new Ast\Type\ConstTypeNode($constExpr), + $startLine, + $startIndex + ); if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { - $type = $this->tryParseArrayOrOffsetAccess($tokens, $this->enrichWithAttributes( - $tokens, - $type, - $startLine, - $startIndex - )); + $type = $this->tryParseArrayOrOffsetAccess($tokens, $type); } return $type; @@ -670,11 +787,11 @@ private function parseCallableReturnType(TokenIterator $tokens): Ast\Type\TypeNo /** @phpstan-impure */ - private function tryParseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $identifier): Ast\Type\TypeNode + private function tryParseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $identifier, bool $hasTemplate): Ast\Type\TypeNode { try { $tokens->pushSavePoint(); - $type = $this->parseCallable($tokens, $identifier); + $type = $this->parseCallable($tokens, $identifier, $hasTemplate); $tokens->dropSavePoint(); } catch (ParserException $e) { @@ -746,6 +863,7 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, $items = []; $sealed = true; + $unsealedType = null; do { $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); @@ -756,6 +874,17 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, if ($tokens->tryConsumeTokenType(Lexer::TOKEN_VARIADIC)) { $sealed = false; + + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { + if ($kind === Ast\Type\ArrayShapeNode::KIND_ARRAY) { + $unsealedType = $this->parseArrayShapeUnsealedType($tokens); + } else { + $unsealedType = $this->parseListShapeUnsealedType($tokens); + } + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + } + $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA); break; } @@ -768,7 +897,7 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET); - return new Ast\Type\ArrayShapeNode($items, $sealed, $kind); + return new Ast\Type\ArrayShapeNode($items, $sealed, $kind, $unsealedType); } @@ -847,6 +976,63 @@ private function parseArrayShapeKey(TokenIterator $tokens) ); } + /** + * @phpstan-impure + */ + private function parseArrayShapeUnsealedType(TokenIterator $tokens): Ast\Type\ArrayShapeUnsealedTypeNode + { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $valueType = $this->parse($tokens); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $keyType = null; + if ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $keyType = $valueType; + $valueType = $this->parse($tokens); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + } + + $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); + + return $this->enrichWithAttributes( + $tokens, + new Ast\Type\ArrayShapeUnsealedTypeNode($valueType, $keyType), + $startLine, + $startIndex + ); + } + + /** + * @phpstan-impure + */ + private function parseListShapeUnsealedType(TokenIterator $tokens): Ast\Type\ArrayShapeUnsealedTypeNode + { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $valueType = $this->parse($tokens); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); + + return $this->enrichWithAttributes( + $tokens, + new Ast\Type\ArrayShapeUnsealedTypeNode($valueType, null), + $startLine, + $startIndex + ); + } + /** * @phpstan-impure */ diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index 0093e6ca..f6665987 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -20,6 +20,9 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueParameterNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamClosureThisTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamOutTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocChildNode; @@ -28,6 +31,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PropertyTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PureUnlessCallableIsImpureTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\RequireExtendsTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\RequireImplementsTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; @@ -40,6 +44,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; @@ -99,6 +104,7 @@ final class Printer ArrayShapeNode::class . '->items' => ', ', ObjectShapeNode::class . '->items' => ', ', CallableTypeNode::class . '->parameters' => ', ', + CallableTypeNode::class . '->templateTypes' => ', ', GenericTypeNode::class . '->genericTypes' => ', ', ConstExprArrayNode::class . '->items' => ', ', MethodTagValueNode::class . '->parameters' => ', ', @@ -137,7 +143,6 @@ final class Printer CallableTypeNode::class, UnionTypeNode::class, IntersectionTypeNode::class, - ConstTypeNode::class, NullableTypeNode::class, ], ]; @@ -226,6 +231,12 @@ function (PhpDocChildNode $child): string { $isOptional = $node->isOptional ? '=' : ''; return trim("{$type}{$isReference}{$isVariadic}{$node->parameterName}") . $isOptional; } + if ($node instanceof ArrayShapeUnsealedTypeNode) { + if ($node->keyType !== null) { + return sprintf('<%s, %s>', $this->printType($node->keyType), $this->printType($node->valueType)); + } + return sprintf('<%s>', $this->printType($node->valueType)); + } if ($node instanceof DoctrineAnnotation) { return (string) $node; } @@ -303,6 +314,18 @@ private function printTagValue(PhpDocTagValueNode $node): string $type = $this->printType($node->type); return trim("{$type} {$reference}{$variadic}{$node->parameterName} {$node->description}"); } + if ($node instanceof ParamImmediatelyInvokedCallableTagValueNode) { + return trim("{$node->parameterName} {$node->description}"); + } + if ($node instanceof ParamLaterInvokedCallableTagValueNode) { + return trim("{$node->parameterName} {$node->description}"); + } + if ($node instanceof ParamClosureThisTagValueNode) { + return trim("{$node->type} {$node->parameterName} {$node->description}"); + } + if ($node instanceof PureUnlessCallableIsImpureTagValueNode) { + return trim("{$node->parameterName} {$node->description}"); + } if ($node instanceof PropertyTagValueNode) { $type = $this->printType($node->type); return trim("{$type} {$node->propertyName} {$node->description}"); @@ -316,9 +339,10 @@ private function printTagValue(PhpDocTagValueNode $node): string return trim($type . ' ' . $node->description); } if ($node instanceof TemplateTagValueNode) { - $bound = $node->bound !== null ? ' of ' . $this->printType($node->bound) : ''; + $upperBound = $node->bound !== null ? ' of ' . $this->printType($node->bound) : ''; + $lowerBound = $node->lowerBound !== null ? ' super ' . $this->printType($node->lowerBound) : ''; $default = $node->default !== null ? ' = ' . $this->printType($node->default) : ''; - return trim("{$node->name}{$bound}{$default} {$node->description}"); + return trim("{$node->name}{$upperBound}{$lowerBound}{$default} {$node->description}"); } if ($node instanceof ThrowsTagValueNode) { $type = $this->printType($node->type); @@ -354,7 +378,7 @@ private function printType(TypeNode $node): string }, $node->items); if (! $node->sealed) { - $items[] = '...'; + $items[] = '...' . ($node->unsealedType === null ? '' : $this->print($node->unsealedType)); } return $node->kind . '{' . implode(', ', $items) . '}'; @@ -380,10 +404,15 @@ private function printType(TypeNode $node): string } else { $returnType = $this->printType($node->returnType); } + $template = $node->templateTypes !== [] + ? '<' . implode(', ', array_map(function (TemplateTagValueNode $templateNode): string { + return $this->print($templateNode); + }, $node->templateTypes)) . '>' + : ''; $parameters = implode(', ', array_map(function (CallableTypeParameterNode $parameterNode): string { return $this->print($parameterNode); }, $node->parameters)); - return "{$node->identifier}({$parameters}): {$returnType}"; + return "{$node->identifier}{$template}({$parameters}): {$returnType}"; } if ($node instanceof ConditionalTypeForParameterNode) { return sprintf( @@ -494,7 +523,6 @@ private function printOffsetAccessType(TypeNode $type): string $type instanceof CallableTypeNode || $type instanceof UnionTypeNode || $type instanceof IntersectionTypeNode - || $type instanceof ConstTypeNode || $type instanceof NullableTypeNode ) { return $this->wrapInParentheses($type); diff --git a/tests/PHPStan/Ast/ToString/PhpDocToStringTest.php b/tests/PHPStan/Ast/ToString/PhpDocToStringTest.php index b57b7db6..74d257ab 100644 --- a/tests/PHPStan/Ast/ToString/PhpDocToStringTest.php +++ b/tests/PHPStan/Ast/ToString/PhpDocToStringTest.php @@ -155,12 +155,15 @@ public static function provideOtherCases(): Generator $baz = new IdentifierTypeNode('Foo\\Baz'); yield from [ - ['TValue', new TemplateTagValueNode('TValue', null, '', null)], - ['TValue of Foo\\Bar', new TemplateTagValueNode('TValue', $bar, '', null)], + ['TValue', new TemplateTagValueNode('TValue', null, '')], + ['TValue of Foo\\Bar', new TemplateTagValueNode('TValue', $bar, '')], + ['TValue super Foo\\Bar', new TemplateTagValueNode('TValue', null, '', null, $bar)], ['TValue = Foo\\Bar', new TemplateTagValueNode('TValue', null, '', $bar)], ['TValue of Foo\\Bar = Foo\\Baz', new TemplateTagValueNode('TValue', $bar, '', $baz)], - ['TValue Description.', new TemplateTagValueNode('TValue', null, 'Description.', null)], + ['TValue Description.', new TemplateTagValueNode('TValue', null, 'Description.')], ['TValue of Foo\\Bar = Foo\\Baz Description.', new TemplateTagValueNode('TValue', $bar, 'Description.', $baz)], + ['TValue super Foo\\Bar = Foo\\Baz Description.', new TemplateTagValueNode('TValue', null, 'Description.', $baz, $bar)], + ['TValue of Foo\\Bar super Foo\\Baz Description.', new TemplateTagValueNode('TValue', $bar, 'Description.', null, $baz)], ]; } diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 67a9d123..1017fd31 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -29,12 +29,16 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueParameterNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamClosureThisTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamOutTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PropertyTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PureUnlessCallableIsImpureTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\RequireExtendsTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\RequireImplementsTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; @@ -97,7 +101,11 @@ protected function setUp(): void * @dataProvider provideTagsWithNumbers * @dataProvider provideSpecializedTags * @dataProvider provideParamTagsData + * @dataProvider provideParamImmediatelyInvokedCallableTagsData + * @dataProvider provideParamLaterInvokedCallableTagsData * @dataProvider provideTypelessParamTagsData + * @dataProvider provideParamClosureThisTagsData + * @dataProvider providePureUnlessCallableIsImpureTagsData * @dataProvider provideVarTagsData * @dataProvider provideReturnTagsData * @dataProvider provideThrowsTagsData @@ -620,6 +628,147 @@ public function provideTypelessParamTagsData(): Iterator ]; } + public function provideParamImmediatelyInvokedCallableTagsData(): Iterator + { + yield [ + 'OK', + '/** @param-immediately-invoked-callable $foo */', + new PhpDocNode([ + new PhpDocTagNode( + '@param-immediately-invoked-callable', + new ParamImmediatelyInvokedCallableTagValueNode( + '$foo', + '' + ) + ), + ]), + ]; + + yield [ + 'OK with description', + '/** @param-immediately-invoked-callable $foo test two three */', + new PhpDocNode([ + new PhpDocTagNode( + '@param-immediately-invoked-callable', + new ParamImmediatelyInvokedCallableTagValueNode( + '$foo', + 'test two three' + ) + ), + ]), + ]; + } + + public function provideParamLaterInvokedCallableTagsData(): Iterator + { + yield [ + 'OK', + '/** @param-later-invoked-callable $foo */', + new PhpDocNode([ + new PhpDocTagNode( + '@param-later-invoked-callable', + new ParamLaterInvokedCallableTagValueNode( + '$foo', + '' + ) + ), + ]), + ]; + + yield [ + 'OK with description', + '/** @param-later-invoked-callable $foo test two three */', + new PhpDocNode([ + new PhpDocTagNode( + '@param-later-invoked-callable', + new ParamLaterInvokedCallableTagValueNode( + '$foo', + 'test two three' + ) + ), + ]), + ]; + } + + public function provideParamClosureThisTagsData(): Iterator + { + yield [ + 'OK', + '/** @param-closure-this Foo $a */', + new PhpDocNode([ + new PhpDocTagNode( + '@param-closure-this', + new ParamClosureThisTagValueNode( + new IdentifierTypeNode('Foo'), + '$a', + '' + ) + ), + ]), + ]; + + yield [ + 'OK with prefix', + '/** @phpstan-param-closure-this Foo $a */', + new PhpDocNode([ + new PhpDocTagNode( + '@phpstan-param-closure-this', + new ParamClosureThisTagValueNode( + new IdentifierTypeNode('Foo'), + '$a', + '' + ) + ), + ]), + ]; + + yield [ + 'OK with description', + '/** @param-closure-this Foo $a test */', + new PhpDocNode([ + new PhpDocTagNode( + '@param-closure-this', + new ParamClosureThisTagValueNode( + new IdentifierTypeNode('Foo'), + '$a', + 'test' + ) + ), + ]), + ]; + } + + public function providePureUnlessCallableIsImpureTagsData(): Iterator + { + yield [ + 'OK', + '/** @pure-unless-callable-is-impure $foo */', + new PhpDocNode([ + new PhpDocTagNode( + '@pure-unless-callable-is-impure', + new PureUnlessCallableIsImpureTagValueNode( + '$foo', + '' + ) + ), + ]), + ]; + + yield [ + 'OK with description', + '/** @pure-unless-callable-is-impure $foo test two three */', + new PhpDocNode([ + new PhpDocTagNode( + '@pure-unless-callable-is-impure', + new PureUnlessCallableIsImpureTagValueNode( + '$foo', + 'test two three' + ) + ), + ]), + ]; + } + public function provideVarTagsData(): Iterator { yield [ @@ -2626,6 +2775,26 @@ public function provideMethodTagsData(): Iterator ), ]), ]; + + yield [ + 'OK non-static with return type that starts with static type', + '/** @method static|null foo() */', + new PhpDocNode([ + new PhpDocTagNode( + '@method', + new MethodTagValueNode( + false, + new UnionTypeNode([ + new IdentifierTypeNode('static'), + new IdentifierTypeNode('null'), + ]), + 'foo', + [], + '' + ) + ), + ]), + ]; } @@ -3745,6 +3914,46 @@ public function provideMultiLinePhpDocData(): iterable new PhpDocTextNode('test'), ]), ]; + + yield [ + 'Real-world test case multiline PHPDoc', + '/**' . PHP_EOL . + ' *' . PHP_EOL . + ' * MultiLine' . PHP_EOL . + ' * description' . PHP_EOL . + ' * @param bool $a' . PHP_EOL . + ' *' . PHP_EOL . + ' * @return void' . PHP_EOL . + ' *' . PHP_EOL . + ' * @throws \Exception' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode(''), + new PhpDocTextNode( + 'MultiLine' . PHP_EOL . + 'description' + ), + new PhpDocTagNode('@param', new ParamTagValueNode( + new IdentifierTypeNode('bool'), + false, + '$a', + '', + false + )), + new PhpDocTextNode(''), + new PhpDocTagNode('@return', new ReturnTagValueNode( + new IdentifierTypeNode('void'), + '' + )), + new PhpDocTextNode(''), + new PhpDocTagNode('@throws', new ThrowsTagValueNode( + new IdentifierTypeNode('\Exception'), + '' + )), + new PhpDocTextNode(''), + ]), + ]; } public function provideTemplateTagsData(): Iterator @@ -3810,7 +4019,7 @@ public function provideTemplateTagsData(): Iterator ]; yield [ - 'OK with bound and description', + 'OK with upper bound and description', '/** @template T of DateTime the value type */', new PhpDocNode([ new PhpDocTagNode( @@ -3825,22 +4034,41 @@ public function provideTemplateTagsData(): Iterator ]; yield [ - 'OK with bound and description', - '/** @template T as DateTime the value type */', + 'OK with lower bound and description', + '/** @template T super DateTimeImmutable the value type */', new PhpDocNode([ new PhpDocTagNode( '@template', new TemplateTagValueNode( 'T', - new IdentifierTypeNode('DateTime'), - 'the value type' + null, + 'the value type', + null, + new IdentifierTypeNode('DateTimeImmutable') + ) + ), + ]), + ]; + + yield [ + 'OK with both bounds and description', + '/** @template T of DateTimeInterface super DateTimeImmutable the value type */', + new PhpDocNode([ + new PhpDocTagNode( + '@template', + new TemplateTagValueNode( + 'T', + new IdentifierTypeNode('DateTimeInterface'), + 'the value type', + null, + new IdentifierTypeNode('DateTimeImmutable') ) ), ]), ]; yield [ - 'invalid without bound and description', + 'invalid without bounds and description', '/** @template */', new PhpDocNode([ new PhpDocTagNode( @@ -7097,6 +7325,9 @@ public function testReturnTypeLinesAndIndexes(string $phpDoc, array $lines): voi * @dataProvider provideSpecializedTags * @dataProvider provideParamTagsData * @dataProvider provideTypelessParamTagsData + * @dataProvider provideParamImmediatelyInvokedCallableTagsData + * @dataProvider provideParamLaterInvokedCallableTagsData + * @dataProvider provideParamClosureThisTagsData * @dataProvider provideVarTagsData * @dataProvider provideReturnTagsData * @dataProvider provideThrowsTagsData @@ -7408,6 +7639,80 @@ public function dataTextBetweenTagsBelongsToDescription(): iterable new PhpDocTagNode('@package', new GenericTagValueNode('foo')), ]), ]; + + yield [ + '/** @deprecated in Drupal 8.6.0 and will be removed before Drupal 9.0.0. In + * Drupal 9 there will be no way to set the status and in Drupal 8 this + * ability has been removed because mb_*() functions are supplied using + * Symfony\'s polyfill. */', + new PhpDocNode([ + new PhpDocTagNode( + '@deprecated', + new DeprecatedTagValueNode('in Drupal 8.6.0 and will be removed before Drupal 9.0.0. In + Drupal 9 there will be no way to set the status and in Drupal 8 this + ability has been removed because mb_*() functions are supplied using + Symfony\'s polyfill.') + ), + ]), + ]; + + yield [ + '/** @\ORM\Column() in Drupal 8.6.0 and will be removed before Drupal 9.0.0. In + * Drupal 9 there will be no way to set the status and in Drupal 8 this + * ability has been removed because mb_*() functions are supplied using + * Symfony\'s polyfill. */', + new PhpDocNode([ + new PhpDocTagNode( + '@\ORM\Column', + new DoctrineTagValueNode( + new DoctrineAnnotation('@\ORM\Column', []), + 'in Drupal 8.6.0 and will be removed before Drupal 9.0.0. In + Drupal 9 there will be no way to set the status and in Drupal 8 this + ability has been removed because mb_*() functions are supplied using + Symfony\'s polyfill.' + ) + ), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' *' . PHP_EOL . + ' * MultiLine' . PHP_EOL . + ' * description' . PHP_EOL . + ' * @param bool $a' . PHP_EOL . + ' *' . PHP_EOL . + ' * @return void' . PHP_EOL . + ' *' . PHP_EOL . + ' * @throws \Exception' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode( + PHP_EOL . + 'MultiLine' . PHP_EOL . + 'description' + ), + new PhpDocTagNode('@param', new ParamTagValueNode( + new IdentifierTypeNode('bool'), + false, + '$a', + '', + false + )), + new PhpDocTextNode(''), + new PhpDocTagNode('@return', new ReturnTagValueNode( + new IdentifierTypeNode('void'), + '' + )), + new PhpDocTextNode(''), + new PhpDocTagNode('@throws', new ThrowsTagValueNode( + new IdentifierTypeNode('\Exception'), + '' + )), + new PhpDocTextNode(''), + ]), + ]; } /** diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php index 0cc294f5..5ff5dd83 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -10,8 +10,10 @@ use PHPStan\PhpDocParser\Ast\ConstExpr\QuoteAwareConstExprStringNode; use PHPStan\PhpDocParser\Ast\Node; use PHPStan\PhpDocParser\Ast\NodeTraverser; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; @@ -759,6 +761,485 @@ public function provideParseData(): array ArrayShapeNode::KIND_LIST ), ], + [ + 'non-empty-array{ + int, + string + }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('string') + ), + ], + true, + ArrayShapeNode::KIND_NON_EMPTY_ARRAY + ), + ], + [ + 'callable(): non-empty-array{int, string}', + new CallableTypeNode(new IdentifierTypeNode('callable'), [], new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('string') + ), + ], + true, + ArrayShapeNode::KIND_NON_EMPTY_ARRAY + )), + ], + [ + 'callable(): non-empty-list{int, string}', + new CallableTypeNode(new IdentifierTypeNode('callable'), [], new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('string') + ), + ], + true, + ArrayShapeNode::KIND_NON_EMPTY_LIST + )), + ], + [ + 'non-empty-list{ + int, + string + }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('string') + ), + ], + true, + ArrayShapeNode::KIND_NON_EMPTY_LIST + ), + ], + [ + 'array{...}', + new ArrayShapeNode( + [], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{a: int, b?: int, ...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{a:int,b?:int,...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{a: int, b?: int, ... ' . PHP_EOL + . ' < ' . PHP_EOL + . ' string ' . PHP_EOL + . ' > ' . PHP_EOL + . ' , ' . PHP_EOL + . ' }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{...}', + new ArrayShapeNode( + [], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ) + ), + ], + [ + 'array{a: int, b?: int, ...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ) + ), + ], + [ + 'array{a:int,b?:int,...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ) + ), + ], + [ + 'array{a: int, b?: int, ... ' . PHP_EOL + . ' < ' . PHP_EOL + . ' int ' . PHP_EOL + . ' , ' . PHP_EOL + . ' string ' . PHP_EOL + . ' > ' . PHP_EOL + . ' , ' . PHP_EOL + . ' }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new IdentifierTypeNode('a'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new IdentifierTypeNode('b'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_ARRAY, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ) + ), + ], + [ + 'list{...}', + new ArrayShapeNode( + [], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{int, int, ...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{int,int,...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{int, int, ... ' . PHP_EOL + . ' < ' . PHP_EOL + . ' string ' . PHP_EOL + . ' > ' . PHP_EOL + . ' , ' . PHP_EOL + . ' }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{0: int, 1?: int, ...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new ConstExprIntegerNode('0'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new ConstExprIntegerNode('1'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{0:int,1?:int,...}', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new ConstExprIntegerNode('0'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new ConstExprIntegerNode('1'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'list{0: int, 1?: int, ... ' . PHP_EOL + . ' < ' . PHP_EOL + . ' string ' . PHP_EOL + . ' > ' . PHP_EOL + . ' , ' . PHP_EOL + . ' }', + new ArrayShapeNode( + [ + new ArrayShapeItemNode( + new ConstExprIntegerNode('0'), + false, + new IdentifierTypeNode('int') + ), + new ArrayShapeItemNode( + new ConstExprIntegerNode('1'), + true, + new IdentifierTypeNode('int') + ), + ], + false, + ArrayShapeNode::KIND_LIST, + new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + null + ) + ), + ], + [ + 'array{...<>}', + new ParserException( + '>', + Lexer::TOKEN_CLOSE_ANGLE_BRACKET, + 10, + Lexer::TOKEN_IDENTIFIER + ), + ], + [ + 'array{...}', + new ParserException( + '>', + Lexer::TOKEN_CLOSE_ANGLE_BRACKET, + 14, + Lexer::TOKEN_IDENTIFIER + ), + ], + [ + 'array{...}', + new ParserException( + ',', + Lexer::TOKEN_COMMA, + 21, + Lexer::TOKEN_CLOSE_ANGLE_BRACKET + ), + ], + [ + 'array{...}', + new ParserException( + ',', + Lexer::TOKEN_COMMA, + 21, + Lexer::TOKEN_CLOSE_ANGLE_BRACKET + ), + ], + [ + 'list{...<>}', + new ParserException( + '>', + Lexer::TOKEN_CLOSE_ANGLE_BRACKET, + 9, + Lexer::TOKEN_IDENTIFIER + ), + ], + [ + 'list{...}', + new ParserException( + ',', + Lexer::TOKEN_COMMA, + 12, + Lexer::TOKEN_CLOSE_ANGLE_BRACKET + ), + ], + [ + 'list{...}', + new ParserException( + ',', + Lexer::TOKEN_COMMA, + 12, + Lexer::TOKEN_CLOSE_ANGLE_BRACKET + ), + ], [ 'callable(): Foo', new CallableTypeNode( @@ -767,6 +1248,22 @@ public function provideParseData(): array new IdentifierTypeNode('Foo') ), ], + [ + 'pure-callable(): Foo', + new CallableTypeNode( + new IdentifierTypeNode('pure-callable'), + [], + new IdentifierTypeNode('Foo') + ), + ], + [ + 'pure-Closure(): Foo', + new CallableTypeNode( + new IdentifierTypeNode('pure-Closure'), + [], + new IdentifierTypeNode('Foo') + ), + ], [ 'callable(): ?Foo', new CallableTypeNode( @@ -897,6 +1394,104 @@ public function provideParseData(): array new IdentifierTypeNode('Foo') ), ], + [ + 'callable(B): C', + new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode( + new IdentifierTypeNode('B'), + false, + false, + '', + false + ), + ], + new IdentifierTypeNode('C'), + [ + new TemplateTagValueNode('A', null, ''), + ] + ), + ], + [ + 'callable<>(): void', + new ParserException( + '>', + Lexer::TOKEN_END, + 9, + Lexer::TOKEN_IDENTIFIER + ), + ], + [ + 'Closure(T, int): (T|false)', + new CallableTypeNode( + new IdentifierTypeNode('Closure'), + [ + new CallableTypeParameterNode( + new IdentifierTypeNode('T'), + false, + false, + '', + false + ), + new CallableTypeParameterNode( + new IdentifierTypeNode('int'), + false, + false, + '', + false + ), + ], + new UnionTypeNode([ + new IdentifierTypeNode('T'), + new IdentifierTypeNode('false'), + ]), + [ + new TemplateTagValueNode('T', new IdentifierTypeNode('Model'), ''), + ] + ), + ], + [ + '\Closure(Tx, Ty): array{ Ty, Tx }', + new CallableTypeNode( + new IdentifierTypeNode('\Closure'), + [ + new CallableTypeParameterNode( + new IdentifierTypeNode('Tx'), + false, + false, + '', + false + ), + new CallableTypeParameterNode( + new IdentifierTypeNode('Ty'), + false, + false, + '', + false + ), + ], + new ArrayShapeNode([ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('Ty') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('Tx') + ), + ]), + [ + new TemplateTagValueNode('Tx', new UnionTypeNode([ + new IdentifierTypeNode('X'), + new IdentifierTypeNode('Z'), + ]), ''), + new TemplateTagValueNode('Ty', new IdentifierTypeNode('Y'), ''), + ] + ), + ], [ '(Foo\\Bar, (int | (string & bar)[])> | Lorem)', new UnionTypeNode([ @@ -952,6 +1547,13 @@ public function provideParseData(): array new IdentifierTypeNode('int') ), ], + [ + 'self::TYPES[ int ]', + new OffsetAccessTypeNode( + new ConstTypeNode(new ConstFetchNode('self', 'TYPES')), + new IdentifierTypeNode('int') + ), + ], [ "?\t\xA009", // edge-case with \h new NullableTypeNode( diff --git a/tests/PHPStan/Printer/PrinterTest.php b/tests/PHPStan/Printer/PrinterTest.php index 2307cdb2..e4effc31 100644 --- a/tests/PHPStan/Printer/PrinterTest.php +++ b/tests/PHPStan/Printer/PrinterTest.php @@ -17,14 +17,19 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArray; use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArrayItem; use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamClosureThisTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PureUnlessCallableIsImpureTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasImportTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; @@ -48,6 +53,7 @@ use function array_splice; use function array_unshift; use function array_values; +use function assert; use function count; use const PHP_EOL; @@ -590,6 +596,35 @@ public function enterNode(Node $node) }; + $addCallableTemplateType = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof CallableTypeNode) { + $node->templateTypes[] = new TemplateTagValueNode( + 'T', + new IdentifierTypeNode('int'), + '' + ); + } + + return $node; + } + + }; + + yield [ + '/** @var Closure(): T */', + '/** @var Closure(): T */', + $addCallableTemplateType, + ]; + + yield [ + '/** @var \Closure(U): T */', + '/** @var \Closure(U): T */', + $addCallableTemplateType, + ]; + yield [ '/** * @param callable(): void $cb @@ -913,6 +948,12 @@ public function enterNode(Node $node) $addTemplateTagBound, ]; + yield [ + '/** @template T super string */', + '/** @template T of int super string */', + $addTemplateTagBound, + ]; + $removeTemplateTagBound = new class extends AbstractNodeVisitor { public function enterNode(Node $node) @@ -932,6 +973,56 @@ public function enterNode(Node $node) $removeTemplateTagBound, ]; + $addTemplateTagLowerBound = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof TemplateTagValueNode) { + $node->lowerBound = new IdentifierTypeNode('int'); + } + + return $node; + } + + }; + + yield [ + '/** @template T */', + '/** @template T super int */', + $addTemplateTagLowerBound, + ]; + + yield [ + '/** @template T super string */', + '/** @template T super int */', + $addTemplateTagLowerBound, + ]; + + yield [ + '/** @template T of string */', + '/** @template T of string super int */', + $addTemplateTagLowerBound, + ]; + + $removeTemplateTagLowerBound = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof TemplateTagValueNode) { + $node->lowerBound = null; + } + + return $node; + } + + }; + + yield [ + '/** @template T super int */', + '/** @template T */', + $removeTemplateTagLowerBound, + ]; + $addKeyNameToArrayShapeItemNode = new class extends AbstractNodeVisitor { public function enterNode(Node $node) @@ -1636,6 +1727,218 @@ public function enterNode(Node $node) }, ]; + + yield [ + '/** @param-immediately-invoked-callable $foo test */', + '/** @param-immediately-invoked-callable $bar foo */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ParamImmediatelyInvokedCallableTagValueNode) { + $node->parameterName = '$bar'; + $node->description = 'foo'; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @param-later-invoked-callable $foo test */', + '/** @param-later-invoked-callable $bar foo */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ParamLaterInvokedCallableTagValueNode) { + $node->parameterName = '$bar'; + $node->description = 'foo'; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @param-closure-this Foo $test haha */', + '/** @param-closure-this Bar $taste hehe */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ParamClosureThisTagValueNode) { + $node->type = new IdentifierTypeNode('Bar'); + $node->parameterName = '$taste'; + $node->description = 'hehe'; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @pure-unless-callable-is-impure $foo test */', + '/** @pure-unless-callable-is-impure $bar foo */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof PureUnlessCallableIsImpureTagValueNode) { + $node->parameterName = '$bar'; + $node->description = 'foo'; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return Foo[abc] */', + '/** @return self::FOO[abc] */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ReturnTagValueNode && $node->type instanceof OffsetAccessTypeNode) { + $node->type->type = new ConstTypeNode(new ConstFetchNode('self', 'FOO')); + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return array{foo: int, ...} */', + '/** @return array{foo: int} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->sealed = true; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return array{foo: int, ...} */', + '/** @return array{foo: int, ...} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->unsealedType = new ArrayShapeUnsealedTypeNode(new IdentifierTypeNode('string'), null); + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return array{foo: int, ...} */', + '/** @return array{foo: int, ...} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + assert($node->unsealedType !== null); + $node->unsealedType->keyType = new IdentifierTypeNode('int'); + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return array{foo: int, ...} */', + '/** @return array{foo: int} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->sealed = true; + $node->unsealedType = null; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return list{int, ...} */', + '/** @return list{int} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->sealed = true; + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return list{int, ...} */', + '/** @return list{int, ...} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->unsealedType = new ArrayShapeUnsealedTypeNode(new IdentifierTypeNode('string'), null); + } + + return $node; + } + + }, + ]; + + yield [ + '/** @return list{int, ...} */', + '/** @return list{int} */', + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $node->sealed = true; + $node->unsealedType = null; + } + + return $node; + } + + }, + ]; } /** @@ -1688,7 +1991,7 @@ public function enterNode(Node $node) } /** - * @return iterable + * @return iterable */ public function dataPrintType(): iterable { @@ -1775,6 +2078,43 @@ public function dataPrintType(): iterable ]), 'Foo|Bar|(Baz|Lorem)', ]; + yield [ + new OffsetAccessTypeNode( + new ConstTypeNode(new ConstFetchNode('self', 'TYPES')), + new IdentifierTypeNode('int') + ), + 'self::TYPES[int]', + ]; + yield [ + new ArrayShapeNode([ + new ArrayShapeItemNode( + new IdentifierTypeNode('name'), + false, + new IdentifierTypeNode('string') + ), + new ArrayShapeItemNode( + new QuoteAwareConstExprStringNode('Full Name', QuoteAwareConstExprStringNode::SINGLE_QUOTED), + false, + new IdentifierTypeNode('string') + ), + ]), + "array{name: string, 'Full Name': string}", + ]; + yield [ + new ObjectShapeNode([ + new ObjectShapeItemNode( + new IdentifierTypeNode('name'), + false, + new IdentifierTypeNode('string') + ), + new ObjectShapeItemNode( + new QuoteAwareConstExprStringNode('Full Name', QuoteAwareConstExprStringNode::SINGLE_QUOTED), + false, + new IdentifierTypeNode('string') + ), + ]), + "object{name: string, 'Full Name': string}", + ]; } /** @@ -1794,7 +2134,7 @@ public function testPrintType(TypeNode $node, string $expectedResult): void } /** - * @return iterable + * @return iterable */ public function dataPrintPhpDocNode(): iterable {