From c44c67c7ac2871011650870b0312e9160d1a4ddf Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 26 Oct 2026 00:09:09 +0700 Subject: [PATCH 1/3] [TypeDeclarationDocblocks] Skip multilines per phpstan-* tag on MergePhpstanDocTagIntoNativeRector --- .../Fixture/keep_native_description.php.inc | 17 ----------- .../skip_multiline_native_description.php.inc | 15 ++++++++++ .../skip_multiline_phpstan_tag.php.inc | 27 +++++++++++++++++ .../MergePhpstanDocTagIntoNativeRector.php | 30 +++++++++++++++++++ 4 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/skip_multiline_native_description.php.inc create mode 100644 rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/skip_multiline_phpstan_tag.php.inc diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/keep_native_description.php.inc b/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/keep_native_description.php.inc index 00a5fe9ea24..00518c30fc7 100644 --- a/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/keep_native_description.php.inc +++ b/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/keep_native_description.php.inc @@ -4,15 +4,6 @@ namespace Rector\Tests\TypeDeclarationDocblocks\Rector\Property\MergePhpstanDocT final class KeepNativeDescription { - /** - * @param string|array|null $excludePattern Optional regex; classes matching this are excluded - * from the layer even when $pattern matches. - * @phpstan-param string|list|null $excludePattern - */ - public function layerPattern(string $name, string|array $pattern, string|array|null $excludePattern = null): self - { - } - /** * @return array some items * @phpstan-return list @@ -36,14 +27,6 @@ namespace Rector\Tests\TypeDeclarationDocblocks\Rector\Property\MergePhpstanDocT final class KeepNativeDescription { - /** - * @param string|list|null $excludePattern Optional regex; classes matching this are excluded - * from the layer even when $pattern matches. - */ - public function layerPattern(string $name, string|array $pattern, string|array|null $excludePattern = null): self - { - } - /** * @return list some items */ diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/skip_multiline_native_description.php.inc b/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/skip_multiline_native_description.php.inc new file mode 100644 index 00000000000..b1d9c354420 --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/skip_multiline_native_description.php.inc @@ -0,0 +1,15 @@ +|null $excludePattern + */ + public function layerPattern(string $name, string|array $pattern, string|array|null $excludePattern = null): self + { + } +} diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/skip_multiline_phpstan_tag.php.inc b/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/skip_multiline_phpstan_tag.php.inc new file mode 100644 index 00000000000..b21f2b59ed0 --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/skip_multiline_phpstan_tag.php.inc @@ -0,0 +1,27 @@ +name]) && $this->isMultiline($phpDocChildNode)) { + return null; + } + if ($phpDocChildNode->name === '@phpstan-param' && $phpDocChildNode->value instanceof ParamTagValueNode) { $phpstanParamNames[] = $phpDocChildNode->value->parameterName; } elseif ($phpDocChildNode->name === '@phpstan-var' && $phpDocChildNode->value instanceof VarTagValueNode) { @@ -133,6 +139,22 @@ public function refactor(Node $node): ?Node return null; } + foreach ($phpDocNode->children as $phpDocChildNode) { + if (! $phpDocChildNode instanceof PhpDocTagNode) { + continue; + } + + // skip multiline overruled native tag, as phpcs does not support moving its multiline description yet + if ($this->isOverruledNativeTag( + $phpDocChildNode, + $phpstanParamNames, + $phpstanVarNames, + $hasPhpstanReturn + ) && $this->isMultiline($phpDocChildNode)) { + return null; + } + } + $hasChanged = false; foreach ($phpDocNode->children as $key => $phpDocChildNode) { @@ -213,6 +235,14 @@ private function isOverruledNativeTag( return false; } + private function isMultiline(PhpDocTagNode $phpDocTagNode): bool + { + $startLine = $phpDocTagNode->getAttribute(Attribute::START_LINE); + $endLine = $phpDocTagNode->getAttribute(Attribute::END_LINE); + + return is_int($startLine) && is_int($endLine) && $endLine > $startLine; + } + private function resolveTagKey(ParamTagValueNode|VarTagValueNode|ReturnTagValueNode $tagValueNode): string { if ($tagValueNode instanceof ParamTagValueNode) { From 149fe1e1ec4a9ef0788a5d4f91231171e36ad8aa Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 26 Oct 2026 00:20:37 +0700 Subject: [PATCH 2/3] final touch: avoid double loop --- .../MergePhpstanDocTagIntoNativeRector.php | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/rules/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector.php b/rules/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector.php index 686a2b13697..1945cbab155 100644 --- a/rules/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector.php +++ b/rules/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector.php @@ -113,6 +113,11 @@ public function refactor(Node $node): ?Node // remember native tag descriptions, so they survive when the native tag is overruled if (in_array($phpDocChildNode->name, self::PHPSTAN_TAG_TO_NATIVE_TAG, true)) { + // skip multiline native tag, as phpcs does not support moving its multiline description yet + if ($this->isMultiline($phpDocChildNode)) { + return null; + } + $tagValueNode = $phpDocChildNode->value; if (($tagValueNode instanceof ParamTagValueNode || $tagValueNode instanceof VarTagValueNode || $tagValueNode instanceof ReturnTagValueNode) && $tagValueNode->description !== '') { $nativeDescriptions[$this->resolveTagKey($tagValueNode)] = $tagValueNode->description; @@ -139,22 +144,6 @@ public function refactor(Node $node): ?Node return null; } - foreach ($phpDocNode->children as $phpDocChildNode) { - if (! $phpDocChildNode instanceof PhpDocTagNode) { - continue; - } - - // skip multiline overruled native tag, as phpcs does not support moving its multiline description yet - if ($this->isOverruledNativeTag( - $phpDocChildNode, - $phpstanParamNames, - $phpstanVarNames, - $hasPhpstanReturn - ) && $this->isMultiline($phpDocChildNode)) { - return null; - } - } - $hasChanged = false; foreach ($phpDocNode->children as $key => $phpDocChildNode) { From 1c065d7e62b5afdc08b1b669e3a43bdecf2583d4 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 26 Oct 2026 00:23:51 +0700 Subject: [PATCH 3/3] final touch: continue to next docblock if skipped --- .../multiline_continue_next_tag.php.inc | 49 ++++++++ .../MergePhpstanDocTagIntoNativeRector.php | 114 ++++++++---------- 2 files changed, 96 insertions(+), 67 deletions(-) create mode 100644 rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/multiline_continue_next_tag.php.inc diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/multiline_continue_next_tag.php.inc b/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/multiline_continue_next_tag.php.inc new file mode 100644 index 00000000000..9b05140de79 --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector/Fixture/multiline_continue_next_tag.php.inc @@ -0,0 +1,49 @@ +|null $excludePattern + * @phpstan-param list $pattern + * @phpstan-param array{ + * strict: bool, + * } $options + * @return array + * @phpstan-return list + */ + public function run(array $pattern, ?array $excludePattern, array $options): array + { + } +} + +?> +----- +|null $excludePattern + * @param list $pattern + * @phpstan-param array{ + * strict: bool, + * } $options + * @return list + */ + public function run(array $pattern, ?array $excludePattern, array $options): array + { + } +} + +?> diff --git a/rules/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector.php b/rules/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector.php index 1945cbab155..9b2286489c7 100644 --- a/rules/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector.php +++ b/rules/TypeDeclarationDocblocks/Rector/Property/MergePhpstanDocTagIntoNativeRector.php @@ -14,6 +14,7 @@ use PHPStan\PhpDocParser\Ast\Attribute; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; @@ -101,9 +102,8 @@ public function refactor(Node $node): ?Node $phpDocNode = $phpDocInfo->getPhpDocNode(); - $phpstanParamNames = []; - $phpstanVarNames = []; - $hasPhpstanReturn = false; + $phpstanTagKeys = []; + $multilineTagKeys = []; $nativeDescriptions = []; foreach ($phpDocNode->children as $phpDocChildNode) { @@ -111,74 +111,60 @@ public function refactor(Node $node): ?Node continue; } - // remember native tag descriptions, so they survive when the native tag is overruled - if (in_array($phpDocChildNode->name, self::PHPSTAN_TAG_TO_NATIVE_TAG, true)) { - // skip multiline native tag, as phpcs does not support moving its multiline description yet - if ($this->isMultiline($phpDocChildNode)) { - return null; - } - - $tagValueNode = $phpDocChildNode->value; - if (($tagValueNode instanceof ParamTagValueNode || $tagValueNode instanceof VarTagValueNode || $tagValueNode instanceof ReturnTagValueNode) && $tagValueNode->description !== '') { - $nativeDescriptions[$this->resolveTagKey($tagValueNode)] = $tagValueNode->description; - } - + $tagValueNode = $this->resolveMergeableTagValueNode($phpDocChildNode); + if (! $tagValueNode instanceof PhpDocTagValueNode) { continue; } - // skip multiline @phpstan-* tag, as phpcs does not support multiline native tag yet - if (isset(self::PHPSTAN_TAG_TO_NATIVE_TAG[$phpDocChildNode->name]) && $this->isMultiline($phpDocChildNode)) { - return null; + $tagKey = $this->resolveTagKey($tagValueNode); + + // skip multiline tag, as phpcs does not support multiline native tag yet + if ($this->isMultiline($phpDocChildNode)) { + $multilineTagKeys[] = $tagKey; } - if ($phpDocChildNode->name === '@phpstan-param' && $phpDocChildNode->value instanceof ParamTagValueNode) { - $phpstanParamNames[] = $phpDocChildNode->value->parameterName; - } elseif ($phpDocChildNode->name === '@phpstan-var' && $phpDocChildNode->value instanceof VarTagValueNode) { - $phpstanVarNames[] = $phpDocChildNode->value->variableName; - } elseif ($phpDocChildNode->name === '@phpstan-return' && $phpDocChildNode->value instanceof ReturnTagValueNode) { - $hasPhpstanReturn = true; + if (isset(self::PHPSTAN_TAG_TO_NATIVE_TAG[$phpDocChildNode->name])) { + $phpstanTagKeys[] = $tagKey; + } elseif ($tagValueNode->description !== '') { + // remember native tag descriptions, so they survive when the native tag is overruled + $nativeDescriptions[$tagKey] = $tagValueNode->description; } } - if ($phpstanParamNames === [] && $phpstanVarNames === [] && ! $hasPhpstanReturn) { + $mergeableTagKeys = array_diff($phpstanTagKeys, $multilineTagKeys); + if ($mergeableTagKeys === []) { return null; } - $hasChanged = false; - foreach ($phpDocNode->children as $key => $phpDocChildNode) { if (! $phpDocChildNode instanceof PhpDocTagNode) { continue; } + $tagValueNode = $this->resolveMergeableTagValueNode($phpDocChildNode); + if (! $tagValueNode instanceof PhpDocTagValueNode) { + continue; + } + + $tagKey = $this->resolveTagKey($tagValueNode); + if (! in_array($tagKey, $mergeableTagKeys, true)) { + continue; + } + + $nativeTagName = self::PHPSTAN_TAG_TO_NATIVE_TAG[$phpDocChildNode->name] ?? null; + // drop the weaker native tag that the @phpstan-* variant overrules - if ($this->isOverruledNativeTag( - $phpDocChildNode, - $phpstanParamNames, - $phpstanVarNames, - $hasPhpstanReturn - )) { + if ($nativeTagName === null) { unset($phpDocNode->children[$key]); - $hasChanged = true; continue; } - // promote the @phpstan-* tag to its native counterpart - $nativeTagName = self::PHPSTAN_TAG_TO_NATIVE_TAG[$phpDocChildNode->name] ?? null; - if ($nativeTagName !== null) { - // keep the description of the overruled native tag - $tagValueNode = $phpDocChildNode->value; - if (($tagValueNode instanceof ParamTagValueNode || $tagValueNode instanceof VarTagValueNode || $tagValueNode instanceof ReturnTagValueNode) && $tagValueNode->description === '') { - $tagValueNode->description = $nativeDescriptions[$this->resolveTagKey($tagValueNode)] ?? ''; - } - - $phpDocNode->children[$key] = new PhpDocTagNode($nativeTagName, $phpDocChildNode->value); - $hasChanged = true; + // promote the @phpstan-* tag to its native counterpart, keeping the description of the overruled native tag + if ($tagValueNode->description === '') { + $tagValueNode->description = $nativeDescriptions[$tagKey] ?? ''; } - } - if (! $hasChanged) { - return null; + $phpDocNode->children[$key] = new PhpDocTagNode($nativeTagName, $tagValueNode); } $phpDocNode->children = array_values($phpDocNode->children); @@ -199,29 +185,23 @@ public function refactor(Node $node): ?Node return $node; } - /** - * @param string[] $phpstanParamNames - * @param string[] $phpstanVarNames - */ - private function isOverruledNativeTag( - PhpDocTagNode $phpDocTagNode, - array $phpstanParamNames, - array $phpstanVarNames, - bool $hasPhpstanReturn - ): bool { - if ($phpDocTagNode->name === '@param' && $phpDocTagNode->value instanceof ParamTagValueNode) { - return in_array($phpDocTagNode->value->parameterName, $phpstanParamNames, true); - } - - if ($phpDocTagNode->name === '@var' && $phpDocTagNode->value instanceof VarTagValueNode) { - return in_array($phpDocTagNode->value->variableName, $phpstanVarNames, true); + private function resolveMergeableTagValueNode( + PhpDocTagNode $phpDocTagNode + ): ParamTagValueNode|VarTagValueNode|ReturnTagValueNode|null { + if (! isset(self::PHPSTAN_TAG_TO_NATIVE_TAG[$phpDocTagNode->name]) && ! in_array( + $phpDocTagNode->name, + self::PHPSTAN_TAG_TO_NATIVE_TAG, + true + )) { + return null; } - if ($phpDocTagNode->name === '@return' && $phpDocTagNode->value instanceof ReturnTagValueNode) { - return $hasPhpstanReturn; + $tagValueNode = $phpDocTagNode->value; + if ($tagValueNode instanceof ParamTagValueNode || $tagValueNode instanceof VarTagValueNode || $tagValueNode instanceof ReturnTagValueNode) { + return $tagValueNode; } - return false; + return null; } private function isMultiline(PhpDocTagNode $phpDocTagNode): bool