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 [