diff --git a/.github/workflows/apiref.yml b/.github/workflows/apiref.yml index 4b3abbb0..d3d55f98 100644 --- a/.github/workflows/apiref.yml +++ b/.github/workflows/apiref.yml @@ -5,7 +5,7 @@ name: "Deploy API Reference" on: push: branches: - - "1.22.x" + - "1.23.x" concurrency: group: "pages" @@ -42,7 +42,7 @@ jobs: run: "cp apigen/favicon.png docs/favicon.png" - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v2 with: path: 'docs' diff --git a/.github/workflows/backward-compatibility.yml b/.github/workflows/backward-compatibility.yml index 103e3470..eb78a350 100644 --- a/.github/workflows/backward-compatibility.yml +++ b/.github/workflows/backward-compatibility.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "1.22.x" + - "1.23.x" jobs: backward-compatibility: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 658c2de1..61dd4466 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "1.22.x" + - "1.23.x" jobs: lint: diff --git a/.github/workflows/merge-maintained-branch.yml b/.github/workflows/merge-maintained-branch.yml index 6ea17262..3aa2b0b3 100644 --- a/.github/workflows/merge-maintained-branch.yml +++ b/.github/workflows/merge-maintained-branch.yml @@ -5,7 +5,7 @@ name: Merge maintained branch on: push: branches: - - "1.21.x" + - "1.22.x" jobs: merge: @@ -19,5 +19,5 @@ jobs: with: github_token: "${{ secrets.PHPSTAN_BOT_TOKEN }}" source_ref: ${{ github.ref }} - target_branch: '1.22.x' + target_branch: '1.23.x' commit_message_template: 'Merge branch {source_ref} into {target_branch}' diff --git a/.github/workflows/test-slevomat-coding-standard.yml b/.github/workflows/test-slevomat-coding-standard.yml index f8bc2912..abd90452 100644 --- a/.github/workflows/test-slevomat-coding-standard.yml +++ b/.github/workflows/test-slevomat-coding-standard.yml @@ -6,7 +6,7 @@ on: pull_request: push: branches: - - "1.22.x" + - "1.23.x" jobs: tests: diff --git a/src/Parser/PhpDocParser.php b/src/Parser/PhpDocParser.php index c92b8093..7b56e3bd 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -14,6 +14,7 @@ use function array_key_exists; use function array_values; use function count; +use function rtrim; use function str_replace; use function trim; @@ -49,6 +50,9 @@ class PhpDocParser /** @var bool */ private $useIndexAttributes; + /** @var bool */ + private $textBetweenTagsBelongsToDescription; + /** * @param array{lines?: bool, indexes?: bool} $usedAttributes */ @@ -58,7 +62,8 @@ public function __construct( bool $requireWhitespaceBeforeDescription = false, bool $preserveTypeAliasesWithInvalidTypes = false, array $usedAttributes = [], - bool $parseDoctrineAnnotations = false + bool $parseDoctrineAnnotations = false, + bool $textBetweenTagsBelongsToDescription = false ) { $this->typeParser = $typeParser; @@ -68,6 +73,7 @@ public function __construct( $this->parseDoctrineAnnotations = $parseDoctrineAnnotations; $this->useLinesAttributes = $usedAttributes['lines'] ?? false; $this->useIndexAttributes = $usedAttributes['indexes'] ?? false; + $this->textBetweenTagsBelongsToDescription = $textBetweenTagsBelongsToDescription; } @@ -214,25 +220,54 @@ private function parseText(TokenIterator $tokens): Ast\PhpDoc\PhpDocTextNode { $text = ''; - while (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { - $text .= $tokens->getSkippedHorizontalWhiteSpaceIfAny() . $tokens->joinUntil(Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END); + $endTokens = [Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END]; + if ($this->textBetweenTagsBelongsToDescription) { + $endTokens = [Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END]; + } + + $savepoint = false; + // if the next token is EOL, everything below is skipped and empty string is returned + while ($this->textBetweenTagsBelongsToDescription || !$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { + $tmpText = $tokens->getSkippedHorizontalWhiteSpaceIfAny() . $tokens->joinUntil(Lexer::TOKEN_PHPDOC_EOL, ...$endTokens); + $text .= $tmpText; + + // stop if we're not at EOL - meaning it's the end of PHPDoc if (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { break; } + if ($this->textBetweenTagsBelongsToDescription) { + if (!$savepoint) { + $tokens->pushSavePoint(); + $savepoint = true; + } elseif ($tmpText !== '') { + $tokens->dropSavePoint(); + $tokens->pushSavePoint(); + } + } + $tokens->pushSavePoint(); $tokens->next(); - if ($tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_TAG, Lexer::TOKEN_DOCTRINE_TAG, Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END)) { + // if we're at EOL, check what's next + // if next is a PHPDoc tag, EOL, or end of PHPDoc, stop + if ($tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_TAG, Lexer::TOKEN_DOCTRINE_TAG, ...$endTokens)) { $tokens->rollback(); break; } + // otherwise if the next is text, continue building the description string + $tokens->dropSavePoint(); $text .= $tokens->getDetectedNewline() ?? "\n"; } + if ($savepoint) { + $tokens->rollback(); + $text = rtrim($text, $tokens->getDetectedNewline() ?? "\n"); + } + return new Ast\PhpDoc\PhpDocTextNode(trim($text, " \t")); } @@ -241,9 +276,19 @@ private function parseOptionalDescriptionAfterDoctrineTag(TokenIterator $tokens) { $text = ''; - while (!$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { - $text .= $tokens->getSkippedHorizontalWhiteSpaceIfAny() . $tokens->joinUntil(Lexer::TOKEN_PHPDOC_TAG, Lexer::TOKEN_DOCTRINE_TAG, Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END); + $endTokens = [Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END]; + if ($this->textBetweenTagsBelongsToDescription) { + $endTokens = [Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END]; + } + + $savepoint = false; + + // if the next token is EOL, everything below is skipped and empty string is returned + while ($this->textBetweenTagsBelongsToDescription || !$tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { + $tmpText = $tokens->getSkippedHorizontalWhiteSpaceIfAny() . $tokens->joinUntil(Lexer::TOKEN_PHPDOC_TAG, Lexer::TOKEN_DOCTRINE_TAG, Lexer::TOKEN_PHPDOC_EOL, ...$endTokens); + $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->isPrecededByHorizontalWhitespace()) { return trim($text . $this->parseText($tokens)->text, " \t"); @@ -278,18 +323,37 @@ private function parseOptionalDescriptionAfterDoctrineTag(TokenIterator $tokens) break; } + if ($this->textBetweenTagsBelongsToDescription) { + if (!$savepoint) { + $tokens->pushSavePoint(); + $savepoint = true; + } elseif ($tmpText !== '') { + $tokens->dropSavePoint(); + $tokens->pushSavePoint(); + } + } + $tokens->pushSavePoint(); $tokens->next(); - if ($tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_TAG, Lexer::TOKEN_DOCTRINE_TAG, Lexer::TOKEN_PHPDOC_EOL, Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END)) { + // if we're at EOL, check what's next + // if next is a PHPDoc tag, EOL, or end of PHPDoc, stop + if ($tokens->isCurrentTokenType(Lexer::TOKEN_PHPDOC_TAG, Lexer::TOKEN_DOCTRINE_TAG, ...$endTokens)) { $tokens->rollback(); break; } + // otherwise if the next is text, continue building the description string + $tokens->dropSavePoint(); $text .= $tokens->getDetectedNewline() ?? "\n"; } + if ($savepoint) { + $tokens->rollback(); + $text = rtrim($text, $tokens->getDetectedNewline() ?? "\n"); + } + return trim($text, " \t"); } diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 817ada88..ebf8e4a5 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -2654,11 +2654,11 @@ public function provideSingleLinePhpDocData(): Iterator } /** - * @return array + * @return iterable> */ - public function provideMultiLinePhpDocData(): array + public function provideMultiLinePhpDocData(): iterable { - return [ + yield from [ [ 'multi-line with two tags', '/** @@ -3560,6 +3560,40 @@ public function provideMultiLinePhpDocData(): array ]), ], ]; + + yield [ + 'Empty lines before end', + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', '')), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + ]), + ]; + + yield [ + 'Empty lines before end 2', + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' * test' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', '')), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + new PhpDocTextNode('test'), + ]), + ]; } public function provideTemplateTagsData(): Iterator @@ -6682,4 +6716,260 @@ public function testDoctrine( $this->assertEquals($expectedAnnotations, $parser->parse($input, $label), $label); } + /** + * @return iterable + */ + public function dataTextBetweenTagsBelongsToDescription(): iterable + { + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' * paramA description' . PHP_EOL . + ' * @param int $b' . PHP_EOL . + ' * paramB description' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', PHP_EOL . ' paramA description')), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$b', PHP_EOL . ' paramB description')), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' *' . PHP_EOL . + ' * @param int $b' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', '')), + new PhpDocTextNode(''), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$b', '')), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a aaaa' . PHP_EOL . + ' * bbbb' . PHP_EOL . + ' *' . PHP_EOL . + ' * ccc' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', 'aaaa' . PHP_EOL . ' bbbb' . PHP_EOL . PHP_EOL . 'ccc')), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @ORM\Column()' . PHP_EOL . + ' * bbbb' . PHP_EOL . + ' *' . PHP_EOL . + ' * ccc' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@ORM\Column', new DoctrineTagValueNode(new DoctrineAnnotation('@ORM\Column', []), PHP_EOL . ' bbbb' . PHP_EOL . PHP_EOL . 'ccc')), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @ORM\Column() aaaa' . PHP_EOL . + ' * bbbb' . PHP_EOL . + ' *' . PHP_EOL . + ' * ccc' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@ORM\Column', new DoctrineTagValueNode(new DoctrineAnnotation('@ORM\Column', []), 'aaaa' . PHP_EOL . ' bbbb' . PHP_EOL . PHP_EOL . 'ccc')), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @ORM\Column() aaaa' . PHP_EOL . + ' * bbbb' . PHP_EOL . + ' *' . PHP_EOL . + ' * ccc' . PHP_EOL . + ' * @param int $b' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@ORM\Column', new DoctrineTagValueNode(new DoctrineAnnotation('@ORM\Column', []), 'aaaa' . PHP_EOL . ' bbbb' . PHP_EOL . PHP_EOL . 'ccc')), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$b', '')), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', '')), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' * test' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', PHP_EOL . PHP_EOL . PHP_EOL . 'test')), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a test' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', 'test')), + new PhpDocTextNode(''), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a test' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', 'test')), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' * test' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', PHP_EOL . ' test')), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' * test' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', PHP_EOL . ' test')), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' * test' . PHP_EOL . + ' *' . PHP_EOL . + ' * test 2' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', PHP_EOL . ' test' . PHP_EOL . PHP_EOL . 'test 2')), + new PhpDocTextNode(''), + ]), + ]; + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @param int $a' . PHP_EOL . + ' * test' . PHP_EOL . + ' *' . PHP_EOL . + ' * test 2' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@param', new ParamTagValueNode(new IdentifierTypeNode('int'), false, '$a', PHP_EOL . ' test' . PHP_EOL . PHP_EOL . 'test 2')), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + ]), + ]; + + yield [ + '/**' . PHP_EOL . + ' * Real description' . PHP_EOL . + ' * @ORM\Column()' . PHP_EOL . + ' * test' . PHP_EOL . + ' *' . PHP_EOL . + ' * test 2' . PHP_EOL . + ' *' . PHP_EOL . + ' *' . PHP_EOL . + ' */', + new PhpDocNode([ + new PhpDocTextNode('Real description'), + new PhpDocTagNode('@ORM\Column', new DoctrineTagValueNode(new DoctrineAnnotation('@ORM\Column', []), PHP_EOL . ' test' . PHP_EOL . PHP_EOL . 'test 2')), + new PhpDocTextNode(''), + new PhpDocTextNode(''), + ]), + ]; + } + + /** + * @dataProvider dataTextBetweenTagsBelongsToDescription + */ + public function testTextBetweenTagsBelongsToDescription( + string $input, + PhpDocNode $expectedPhpDocNode + ): void + { + $constExprParser = new ConstExprParser(); + $typeParser = new TypeParser($constExprParser); + $phpDocParser = new PhpDocParser($typeParser, $constExprParser, true, true, [], true, true); + + $tokens = new TokenIterator($this->lexer->tokenize($input)); + $actualPhpDocNode = $phpDocParser->parse($tokens); + + $this->assertEquals($expectedPhpDocNode, $actualPhpDocNode); + $this->assertSame((string) $expectedPhpDocNode, (string) $actualPhpDocNode); + $this->assertSame(Lexer::TOKEN_END, $tokens->currentTokenType()); + } + }