* Dariusz Rumiński * * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ namespace PhpCsFixer\Fixer\Phpdoc; use PhpCsFixer\AbstractFixer; use PhpCsFixer\DocBlock\DocBlock; use PhpCsFixer\DocBlock\TypeExpression; use PhpCsFixer\Fixer\ConfigurableFixerInterface; use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface; use PhpCsFixer\FixerConfiguration\AllowedValueSubset; use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver; use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface; use PhpCsFixer\FixerConfiguration\FixerOptionBuilder; use PhpCsFixer\FixerDefinition\CodeSample; use PhpCsFixer\FixerDefinition\FixerDefinition; use PhpCsFixer\FixerDefinition\FixerDefinitionInterface; use PhpCsFixer\Preg; use PhpCsFixer\Tokenizer\Token; use PhpCsFixer\Tokenizer\Tokens; /** * @author Fabien Potencier * @author Jordi Boggiano * @author Sebastiaan Stok * @author Graham Campbell * @author Dariusz Rumiński */ final class PhpdocAlignFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface { /** * @internal */ public const ALIGN_LEFT = 'left'; /** * @internal */ public const ALIGN_VERTICAL = 'vertical'; private const ALIGNABLE_TAGS = [ 'param', 'property', 'property-read', 'property-write', 'return', 'throws', 'type', 'var', 'method', ]; private const TAGS_WITH_NAME = [ 'param', 'property', ]; private const TAGS_WITH_METHOD_SIGNATURE = [ 'method', ]; /** * @var string */ private $regex; /** * @var string */ private $regexCommentLine; /** * @var string */ private $align; /** * {@inheritdoc} */ public function configure(array $configuration): void { parent::configure($configuration); $tagsWithNameToAlign = array_intersect($this->configuration['tags'], self::TAGS_WITH_NAME); $tagsWithMethodSignatureToAlign = array_intersect($this->configuration['tags'], self::TAGS_WITH_METHOD_SIGNATURE); $tagsWithoutNameToAlign = array_diff($this->configuration['tags'], $tagsWithNameToAlign, $tagsWithMethodSignatureToAlign); $types = []; $indent = '(?P(?:\ {2}|\t)*)'; // e.g. @param <$var> if ([] !== $tagsWithNameToAlign) { $types[] = '(?P'.implode('|', $tagsWithNameToAlign).')\s+(?P(?:'.TypeExpression::REGEX_TYPES.')?)\s+(?P(?:&|\.{3})?\$\S+)'; } // e.g. @return if ([] !== $tagsWithoutNameToAlign) { $types[] = '(?P'.implode('|', $tagsWithoutNameToAlign).')\s+(?P(?:'.TypeExpression::REGEX_TYPES.')?)'; } // e.g. @method if ([] !== $tagsWithMethodSignatureToAlign) { $types[] = '(?P'.implode('|', $tagsWithMethodSignatureToAlign).')(\s+(?P[^\s(]+)|)\s+(?P.+\))'; } // optional $desc = '(?:\s+(?P\V*))'; $this->regex = '/^'.$indent.'\ \*\ @(?J)(?:'.implode('|', $types).')'.$desc.'\s*$/ux'; $this->regexCommentLine = '/^'.$indent.' \*(?! @)(?:\s+(?P\V+))(?align = $this->configuration['align']; } /** * {@inheritdoc} */ public function getDefinition(): FixerDefinitionInterface { $code = <<<'EOF' self::ALIGN_VERTICAL]), new CodeSample($code, ['align' => self::ALIGN_LEFT]), ] ); } /** * {@inheritdoc} * * Must run after AlignMultilineCommentFixer, CommentToPhpdocFixer, CommentToPhpdocFixer, GeneralPhpdocAnnotationRemoveFixer, GeneralPhpdocTagRenameFixer, NoBlankLinesAfterPhpdocFixer, NoEmptyPhpdocFixer, NoSuperfluousPhpdocTagsFixer, PhpdocAddMissingParamAnnotationFixer, PhpdocAddMissingParamAnnotationFixer, PhpdocAnnotationWithoutDotFixer, PhpdocIndentFixer, PhpdocIndentFixer, PhpdocInlineTagNormalizerFixer, PhpdocLineSpanFixer, PhpdocNoAccessFixer, PhpdocNoAliasTagFixer, PhpdocNoEmptyReturnFixer, PhpdocNoPackageFixer, PhpdocNoUselessInheritdocFixer, PhpdocOrderByValueFixer, PhpdocOrderFixer, PhpdocReturnSelfReferenceFixer, PhpdocScalarFixer, PhpdocScalarFixer, PhpdocSeparationFixer, PhpdocSingleLineVarSpacingFixer, PhpdocSummaryFixer, PhpdocTagCasingFixer, PhpdocTagTypeFixer, PhpdocToCommentFixer, PhpdocToCommentFixer, PhpdocToParamTypeFixer, PhpdocToPropertyTypeFixer, PhpdocToReturnTypeFixer, PhpdocTrimConsecutiveBlankLineSeparationFixer, PhpdocTrimFixer, PhpdocTypesFixer, PhpdocTypesFixer, PhpdocTypesOrderFixer, PhpdocVarAnnotationCorrectOrderFixer, PhpdocVarWithoutNameFixer. */ public function getPriority(): int { /* * Should be run after all other docblock fixers. This because they * modify other annotations to change their type and or separation * which totally change the behavior of this fixer. It's important that * annotations are of the correct type, and are grouped correctly * before running this fixer. */ return -42; } /** * {@inheritdoc} */ public function isCandidate(Tokens $tokens): bool { return $tokens->isTokenKindFound(T_DOC_COMMENT); } /** * {@inheritdoc} */ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void { foreach ($tokens as $index => $token) { if (!$token->isGivenKind(T_DOC_COMMENT)) { continue; } $content = $token->getContent(); $docBlock = new DocBlock($content); $this->fixDocBlock($docBlock); $newContent = $docBlock->getContent(); if ($newContent !== $content) { $tokens[$index] = new Token([T_DOC_COMMENT, $newContent]); } } } /** * {@inheritdoc} */ protected function createConfigurationDefinition(): FixerConfigurationResolverInterface { $tags = new FixerOptionBuilder('tags', 'The tags that should be aligned.'); $tags ->setAllowedTypes(['array']) ->setAllowedValues([new AllowedValueSubset(self::ALIGNABLE_TAGS)]) ->setDefault([ 'method', 'param', 'property', 'return', 'throws', 'type', 'var', ]) ; $align = new FixerOptionBuilder('align', 'Align comments'); $align ->setAllowedTypes(['string']) ->setAllowedValues([self::ALIGN_LEFT, self::ALIGN_VERTICAL]) ->setDefault(self::ALIGN_VERTICAL) ; return new FixerConfigurationResolver([$tags->getOption(), $align->getOption()]); } private function fixDocBlock(DocBlock $docBlock): void { $lineEnding = $this->whitespacesConfig->getLineEnding(); for ($i = 0, $l = \count($docBlock->getLines()); $i < $l; ++$i) { $items = []; $matches = $this->getMatches($docBlock->getLine($i)->getContent()); if (null === $matches) { continue; } $current = $i; $items[] = $matches; while (true) { if (null === $docBlock->getLine(++$i)) { break 2; } $matches = $this->getMatches($docBlock->getLine($i)->getContent(), true); if (null === $matches) { break; } $items[] = $matches; } // compute the max length of the tag, hint and variables $tagMax = 0; $hintMax = 0; $varMax = 0; foreach ($items as $item) { if (null === $item['tag']) { continue; } $tagMax = max($tagMax, \strlen($item['tag'])); $hintMax = max($hintMax, \strlen($item['hint'])); $varMax = max($varMax, \strlen($item['var'])); } $currTag = null; // update foreach ($items as $j => $item) { if (null === $item['tag']) { if ('@' === $item['desc'][0]) { $docBlock->getLine($current + $j)->setContent($item['indent'].' * '.$item['desc'].$lineEnding); continue; } $extraIndent = 2; if (\in_array($currTag, self::TAGS_WITH_NAME, true) || \in_array($currTag, self::TAGS_WITH_METHOD_SIGNATURE, true)) { $extraIndent = 3; } $line = $item['indent'] .' * ' .$this->getIndent( $tagMax + $hintMax + $varMax + $extraIndent, $this->getLeftAlignedDescriptionIndent($items, $j) ) .$item['desc'] .$lineEnding; $docBlock->getLine($current + $j)->setContent($line); continue; } $currTag = $item['tag']; $line = $item['indent'] .' * @' .$item['tag'] .$this->getIndent( $tagMax - \strlen($item['tag']) + 1, $item['hint'] ? 1 : 0 ) .$item['hint'] ; if (!empty($item['var'])) { $line .= $this->getIndent(($hintMax ?: -1) - \strlen($item['hint']) + 1) .$item['var'] .( !empty($item['desc']) ? $this->getIndent($varMax - \strlen($item['var']) + 1).$item['desc'].$lineEnding : $lineEnding ) ; } elseif (!empty($item['desc'])) { $line .= $this->getIndent($hintMax - \strlen($item['hint']) + 1).$item['desc'].$lineEnding; } else { $line .= $lineEnding; } $docBlock->getLine($current + $j)->setContent($line); } } } /** * @return null|array */ private function getMatches(string $line, bool $matchCommentOnly = false): ?array { if (Preg::match($this->regex, $line, $matches)) { if (!empty($matches['tag2'])) { $matches['tag'] = $matches['tag2']; $matches['hint'] = $matches['hint2']; $matches['var'] = ''; } if (!empty($matches['tag3'])) { $matches['tag'] = $matches['tag3']; $matches['hint'] = $matches['hint3']; $matches['var'] = $matches['signature']; } if (isset($matches['hint'])) { $matches['hint'] = trim($matches['hint']); } return $matches; } if ($matchCommentOnly && Preg::match($this->regexCommentLine, $line, $matches)) { $matches['tag'] = null; $matches['var'] = ''; $matches['hint'] = ''; return $matches; } return null; } private function getIndent(int $verticalAlignIndent, int $leftAlignIndent = 1): string { $indent = self::ALIGN_VERTICAL === $this->align ? $verticalAlignIndent : $leftAlignIndent; return str_repeat(' ', $indent); } /** * @param array[] $items */ private function getLeftAlignedDescriptionIndent(array $items, int $index): int { if (self::ALIGN_LEFT !== $this->align) { return 0; } // Find last tagged line: $item = null; for (; $index >= 0; --$index) { $item = $items[$index]; if (null !== $item['tag']) { break; } } // No last tag found — no indent: if (null === $item) { return 0; } // Indent according to existing values: return $this->getSentenceIndent($item['tag']) + $this->getSentenceIndent($item['hint']) + $this->getSentenceIndent($item['var']); } /** * Get indent for sentence. */ private function getSentenceIndent(?string $sentence): int { if (null === $sentence) { return 0; } $length = \strlen($sentence); return 0 === $length ? 0 : $length + 1; } }