diff --git a/src/Parser/PhpDocParser.php b/src/Parser/PhpDocParser.php index ecfbc3a..531e0b8 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -12,7 +12,6 @@ class PhpDocParser private const DISALLOWED_DESCRIPTION_START_TOKENS = [ Lexer::TOKEN_UNION, Lexer::TOKEN_INTERSECTION, - Lexer::TOKEN_OPEN_ANGLE_BRACKET, ]; /** @var TypeParser */ diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index ec14b80..c123ee3 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -65,6 +65,14 @@ class TypeParser if (!$tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_COLON)) { $tokens->dropSavePoint(); // because of ConstFetchNode if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { + $tokens->pushSavePoint(); + + $isHtml = $this->isHtml($tokens); + $tokens->rollback(); + if ($isHtml) { + return $type; + } + $type = $this->parseGeneric($tokens, $type); if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { @@ -161,6 +169,35 @@ class TypeParser return new Ast\Type\NullableTypeNode($type); } + public function isHtml(TokenIterator $tokens): bool + { + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); + + if (!$tokens->isCurrentTokenType(Lexer::TOKEN_IDENTIFIER)) { + return false; + } + + $htmlTagName = $tokens->currentTokenValue(); + + $tokens->next(); + + if (!$tokens->tryConsumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET)) { + return false; + } + + while (!$tokens->isCurrentTokenType(Lexer::TOKEN_END)) { + if ( + $tokens->tryConsumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET) + && strpos($tokens->currentTokenValue(), '/' . $htmlTagName . '>') !== false + ) { + return true; + } + + $tokens->next(); + } + + return false; + } public function parseGeneric(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $baseType): Ast\Type\GenericTypeNode { diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 6efd5da..abc6e55 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -66,6 +66,7 @@ class PhpDocParserTest extends \PHPUnit\Framework\TestCase * @dataProvider provideTemplateTagsData * @dataProvider provideExtendsTagsData * @dataProvider provideRealWorldExampleData + * @dataProvider provideDescriptionWithOrWithoutHtml * @param string $label * @param string $input * @param PhpDocNode $expectedPhpDocNode @@ -3130,6 +3131,78 @@ chunk. Must be higher that in the previous request.'), ]; } + public function provideDescriptionWithOrWithoutHtml(): \Iterator + { + yield [ + 'Description with HTML tags in @return tag (close tags together)', + '/**' . PHP_EOL . + ' * @return Foo Important description' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTagNode( + '@return', + new ReturnTagValueNode( + new IdentifierTypeNode('Foo'), + 'Important description' + ) + ), + ]), + ]; + + yield [ + 'Description with HTML tags in @throws tag (closed tags with text between)', + '/**' . PHP_EOL . + ' * @throws FooException Important description etc' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTagNode( + '@throws', + new ThrowsTagValueNode( + new IdentifierTypeNode('FooException'), + 'Important description etc' + ) + ), + ]), + ]; + + yield [ + 'Description with HTML tags in @mixin tag', + '/**' . PHP_EOL . + ' * @mixin Mixin Important description' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTagNode( + '@mixin', + new MixinTagValueNode( + new IdentifierTypeNode('Mixin'), + 'Important description' + ) + ), + ]), + ]; + + yield [ + 'Description with unclosed HTML tags in @return tag - unclosed HTML tag is parsed as generics', + '/**' . PHP_EOL . + ' * @return Foo Important description' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTagNode( + '@return', + new ReturnTagValueNode( + new GenericTypeNode( + new IdentifierTypeNode('Foo'), + [ + new IdentifierTypeNode('strong'), + ] + ), + 'Important description' + ) + ), + ]), + ]; + } + public function dataParseTagValue(): array { return [