PhpdocAlignFixer.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. <?php
  2. declare(strict_types=1);
  3. /*
  4. * This file is part of PHP CS Fixer.
  5. *
  6. * (c) Fabien Potencier <fabien@symfony.com>
  7. * Dariusz Rumiński <dariusz.ruminski@gmail.com>
  8. *
  9. * This source file is subject to the MIT license that is bundled
  10. * with this source code in the file LICENSE.
  11. */
  12. namespace PhpCsFixer\Fixer\Phpdoc;
  13. use PhpCsFixer\AbstractFixer;
  14. use PhpCsFixer\DocBlock\DocBlock;
  15. use PhpCsFixer\DocBlock\TypeExpression;
  16. use PhpCsFixer\Fixer\ConfigurableFixerInterface;
  17. use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
  18. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
  19. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
  20. use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
  21. use PhpCsFixer\FixerDefinition\CodeSample;
  22. use PhpCsFixer\FixerDefinition\FixerDefinition;
  23. use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
  24. use PhpCsFixer\Preg;
  25. use PhpCsFixer\Tokenizer\Token;
  26. use PhpCsFixer\Tokenizer\Tokens;
  27. use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
  28. /**
  29. * @author Fabien Potencier <fabien@symfony.com>
  30. * @author Jordi Boggiano <j.boggiano@seld.be>
  31. * @author Sebastiaan Stok <s.stok@rollerscapes.net>
  32. * @author Graham Campbell <hello@gjcampbell.co.uk>
  33. * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
  34. * @author Jakub Kwaśniewski <jakub@zero-85.pl>
  35. */
  36. final class PhpdocAlignFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
  37. {
  38. /**
  39. * @internal
  40. */
  41. public const ALIGN_LEFT = 'left';
  42. /**
  43. * @internal
  44. */
  45. public const ALIGN_VERTICAL = 'vertical';
  46. private const DEFAULT_TAGS = [
  47. 'method',
  48. 'param',
  49. 'property',
  50. 'return',
  51. 'throws',
  52. 'type',
  53. 'var',
  54. ];
  55. private const TAGS_WITH_NAME = [
  56. 'param',
  57. 'property',
  58. 'property-read',
  59. 'property-write',
  60. 'phpstan-param',
  61. 'phpstan-property',
  62. 'phpstan-property-read',
  63. 'phpstan-property-write',
  64. 'phpstan-assert',
  65. 'phpstan-assert-if-true',
  66. 'phpstan-assert-if-false',
  67. 'psalm-param',
  68. 'psalm-param-out',
  69. 'psalm-property',
  70. 'psalm-property-read',
  71. 'psalm-property-write',
  72. 'psalm-assert',
  73. 'psalm-assert-if-true',
  74. 'psalm-assert-if-false',
  75. ];
  76. private const TAGS_WITH_METHOD_SIGNATURE = [
  77. 'method',
  78. 'phpstan-method',
  79. 'psalm-method',
  80. ];
  81. private const DEFAULT_SPACING = 1;
  82. private const DEFAULT_SPACING_KEY = '_default';
  83. private string $regex;
  84. private string $regexCommentLine;
  85. private string $align;
  86. /**
  87. * same spacing for all or specific for different tags.
  88. *
  89. * @var array<string, int>|int
  90. */
  91. private $spacing = 1;
  92. public function configure(array $configuration): void
  93. {
  94. parent::configure($configuration);
  95. $tagsWithNameToAlign = array_intersect($this->configuration['tags'], self::TAGS_WITH_NAME);
  96. $tagsWithMethodSignatureToAlign = array_intersect($this->configuration['tags'], self::TAGS_WITH_METHOD_SIGNATURE);
  97. $tagsWithoutNameToAlign = array_diff($this->configuration['tags'], $tagsWithNameToAlign, $tagsWithMethodSignatureToAlign);
  98. $indentRegex = '^(?P<indent>(?:\ {2}|\t)*)\ ?';
  99. $types = [];
  100. // e.g. @param <hint> <$var>
  101. if ([] !== $tagsWithNameToAlign) {
  102. $types[] = '(?P<tag>'.implode('|', $tagsWithNameToAlign).')\s+(?P<hint>(?:'.TypeExpression::REGEX_TYPES.')?)\s*(?P<var>(?:&|\.{3})?\$\S+)';
  103. }
  104. // e.g. @return <hint>
  105. if ([] !== $tagsWithoutNameToAlign) {
  106. $types[] = '(?P<tag2>'.implode('|', $tagsWithoutNameToAlign).')\s+(?P<hint2>(?:'.TypeExpression::REGEX_TYPES.')?)';
  107. }
  108. // e.g. @method <hint> <signature>
  109. if ([] !== $tagsWithMethodSignatureToAlign) {
  110. $types[] = '(?P<tag3>'.implode('|', $tagsWithMethodSignatureToAlign).')(\s+(?P<static>static))?(\s+(?P<hint3>(?:'.TypeExpression::REGEX_TYPES.')?))\s+(?P<signature>.+\))';
  111. }
  112. // optional <desc>
  113. $desc = '(?:\s+(?P<desc>\V*))';
  114. $this->regex = '/'.$indentRegex.'\*\h*@(?J)(?:'.implode('|', $types).')'.$desc.'\h*\r?$/';
  115. $this->regexCommentLine = '/'.$indentRegex.'\*(?!\h?+@)(?:\s+(?P<desc>\V+))(?<!\*\/)\r?$/';
  116. $this->align = $this->configuration['align'];
  117. $this->spacing = $this->configuration['spacing'];
  118. }
  119. public function getDefinition(): FixerDefinitionInterface
  120. {
  121. $code = <<<'EOF'
  122. <?php
  123. /**
  124. * @param EngineInterface $templating
  125. * @param string $format
  126. * @param int $code an HTTP response status code
  127. * @param bool $debug
  128. * @param mixed &$reference a parameter passed by reference
  129. *
  130. * @return Foo description foo
  131. *
  132. * @throws Foo description foo
  133. * description foo
  134. *
  135. */
  136. EOF;
  137. return new FixerDefinition(
  138. 'All items of the given PHPDoc tags must be either left-aligned or (by default) aligned vertically.',
  139. [
  140. new CodeSample($code),
  141. new CodeSample($code, ['align' => self::ALIGN_VERTICAL]),
  142. new CodeSample($code, ['align' => self::ALIGN_LEFT]),
  143. new CodeSample($code, ['align' => self::ALIGN_LEFT, 'spacing' => 2]),
  144. new CodeSample($code, ['align' => self::ALIGN_LEFT, 'spacing' => ['param' => 2]]),
  145. ],
  146. );
  147. }
  148. /**
  149. * {@inheritdoc}
  150. *
  151. * Must run after AlignMultilineCommentFixer, CommentToPhpdocFixer, GeneralPhpdocAnnotationRemoveFixer, GeneralPhpdocTagRenameFixer, NoBlankLinesAfterPhpdocFixer, NoEmptyPhpdocFixer, NoSuperfluousPhpdocTagsFixer, PhpdocAddMissingParamAnnotationFixer, PhpdocAnnotationWithoutDotFixer, PhpdocArrayTypeFixer, PhpdocIndentFixer, PhpdocInlineTagNormalizerFixer, PhpdocLineSpanFixer, PhpdocListTypeFixer, PhpdocNoAccessFixer, PhpdocNoAliasTagFixer, PhpdocNoEmptyReturnFixer, PhpdocNoPackageFixer, PhpdocNoUselessInheritdocFixer, PhpdocOrderByValueFixer, PhpdocOrderFixer, PhpdocParamOrderFixer, PhpdocReadonlyClassCommentToKeywordFixer, PhpdocReturnSelfReferenceFixer, PhpdocScalarFixer, PhpdocSeparationFixer, PhpdocSingleLineVarSpacingFixer, PhpdocSummaryFixer, PhpdocTagCasingFixer, PhpdocTagTypeFixer, PhpdocToCommentFixer, PhpdocToParamTypeFixer, PhpdocToPropertyTypeFixer, PhpdocToReturnTypeFixer, PhpdocTrimConsecutiveBlankLineSeparationFixer, PhpdocTrimFixer, PhpdocTypesFixer, PhpdocTypesOrderFixer, PhpdocVarAnnotationCorrectOrderFixer, PhpdocVarWithoutNameFixer.
  152. */
  153. public function getPriority(): int
  154. {
  155. /*
  156. * Should be run after all other docblock fixers. This because they
  157. * modify other annotations to change their type and or separation
  158. * which totally change the behavior of this fixer. It's important that
  159. * annotations are of the correct type, and are grouped correctly
  160. * before running this fixer.
  161. */
  162. return -42;
  163. }
  164. public function isCandidate(Tokens $tokens): bool
  165. {
  166. return $tokens->isTokenKindFound(T_DOC_COMMENT);
  167. }
  168. protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
  169. {
  170. foreach ($tokens as $index => $token) {
  171. if (!$token->isGivenKind(T_DOC_COMMENT)) {
  172. continue;
  173. }
  174. $content = $token->getContent();
  175. $docBlock = new DocBlock($content);
  176. $this->fixDocBlock($docBlock);
  177. $newContent = $docBlock->getContent();
  178. if ($newContent !== $content) {
  179. $tokens[$index] = new Token([T_DOC_COMMENT, $newContent]);
  180. }
  181. }
  182. }
  183. protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
  184. {
  185. $allowPositiveIntegers = static function ($value) {
  186. $spacings = \is_array($value) ? $value : [$value];
  187. foreach ($spacings as $val) {
  188. if (\is_int($val) && $val <= 0) {
  189. throw new InvalidOptionsException('The option "spacing" is invalid. All spacings must be greater than zero.');
  190. }
  191. }
  192. return true;
  193. };
  194. $tags = new FixerOptionBuilder(
  195. 'tags',
  196. 'The tags that should be aligned. Allowed values are tags with name (`\''.implode('\', \'', self::TAGS_WITH_NAME).'\'`), tags with method signature (`\''.implode('\', \'', self::TAGS_WITH_METHOD_SIGNATURE).'\'`) and any custom tag with description (e.g. `@tag <desc>`).'
  197. );
  198. $tags
  199. ->setAllowedTypes(['string[]'])
  200. ->setDefault(self::DEFAULT_TAGS)
  201. ;
  202. $align = new FixerOptionBuilder('align', 'How comments should be aligned.');
  203. $align
  204. ->setAllowedTypes(['string'])
  205. ->setAllowedValues([self::ALIGN_LEFT, self::ALIGN_VERTICAL])
  206. ->setDefault(self::ALIGN_VERTICAL)
  207. ;
  208. $spacing = new FixerOptionBuilder(
  209. 'spacing',
  210. 'Spacing between tag, hint, comment, signature, etc. You can set same spacing for all tags using a positive integer or different spacings for different tags using an associative array of positive integers `[\'tagA\' => spacingForA, \'tagB\' => spacingForB]`. If you want to define default spacing to more than 1 space use `_default` key in config array, e.g.: `[\'tagA\' => spacingForA, \'tagB\' => spacingForB, \'_default\' => spacingForAllOthers]`.'
  211. );
  212. $spacing->setAllowedTypes(['int', 'int[]'])
  213. ->setAllowedValues([$allowPositiveIntegers])
  214. ->setDefault(self::DEFAULT_SPACING)
  215. ;
  216. return new FixerConfigurationResolver([$tags->getOption(), $align->getOption(), $spacing->getOption()]);
  217. }
  218. private function fixDocBlock(DocBlock $docBlock): void
  219. {
  220. $lineEnding = $this->whitespacesConfig->getLineEnding();
  221. for ($i = 0, $l = \count($docBlock->getLines()); $i < $l; ++$i) {
  222. $matches = $this->getMatches($docBlock->getLine($i)->getContent());
  223. if (null === $matches) {
  224. continue;
  225. }
  226. $current = $i;
  227. $items = [$matches];
  228. while (true) {
  229. if (null === $docBlock->getLine(++$i)) {
  230. break 2;
  231. }
  232. $matches = $this->getMatches($docBlock->getLine($i)->getContent(), true);
  233. if (null === $matches) {
  234. break;
  235. }
  236. $items[] = $matches;
  237. }
  238. // compute the max length of the tag, hint and variables
  239. $hasStatic = false;
  240. $tagMax = 0;
  241. $hintMax = 0;
  242. $varMax = 0;
  243. foreach ($items as $item) {
  244. if (null === $item['tag']) {
  245. continue;
  246. }
  247. $hasStatic |= '' !== $item['static'];
  248. $tagMax = max($tagMax, \strlen($item['tag']));
  249. $hintMax = max($hintMax, \strlen($item['hint']));
  250. $varMax = max($varMax, \strlen($item['var']));
  251. }
  252. $itemOpeningLine = null;
  253. $currTag = null;
  254. $spacingForTag = $this->spacingForTag($currTag);
  255. // update
  256. foreach ($items as $j => $item) {
  257. if (null === $item['tag']) {
  258. if ('@' === $item['desc'][0]) {
  259. $line = $item['indent'].' * '.$item['desc'];
  260. $docBlock->getLine($current + $j)->setContent($line.$lineEnding);
  261. continue;
  262. }
  263. $extraIndent = 2 * $spacingForTag;
  264. if (\in_array($itemOpeningLine['tag'], self::TAGS_WITH_NAME, true) || \in_array($itemOpeningLine['tag'], self::TAGS_WITH_METHOD_SIGNATURE, true)) {
  265. $extraIndent += $varMax + $spacingForTag;
  266. }
  267. if ($hasStatic) {
  268. $extraIndent += 7; // \strlen('static ');
  269. }
  270. $line =
  271. $item['indent']
  272. .' * '
  273. .('' !== $itemOpeningLine['hint'] ? ' ' : '')
  274. .$this->getIndent(
  275. $tagMax + $hintMax + $extraIndent,
  276. $this->getLeftAlignedDescriptionIndent($items, $j)
  277. )
  278. .$item['desc'];
  279. $docBlock->getLine($current + $j)->setContent($line.$lineEnding);
  280. continue;
  281. }
  282. $currTag = $item['tag'];
  283. $spacingForTag = $this->spacingForTag($currTag);
  284. $itemOpeningLine = $item;
  285. $line =
  286. $item['indent']
  287. .' * @'
  288. .$item['tag'];
  289. if ($hasStatic) {
  290. $line .=
  291. $this->getIndent(
  292. $tagMax - \strlen($item['tag']) + $spacingForTag,
  293. '' !== $item['static'] ? $spacingForTag : 0
  294. )
  295. .('' !== $item['static'] ? $item['static'] : $this->getIndent(6 /* \strlen('static') */, 0));
  296. $hintVerticalAlignIndent = $spacingForTag;
  297. } else {
  298. $hintVerticalAlignIndent = $tagMax - \strlen($item['tag']) + $spacingForTag;
  299. }
  300. $line .=
  301. $this->getIndent(
  302. $hintVerticalAlignIndent,
  303. '' !== $item['hint'] ? $spacingForTag : 0
  304. )
  305. .$item['hint'];
  306. if ('' !== $item['var']) {
  307. $line .=
  308. $this->getIndent((0 !== $hintMax ? $hintMax : -1) - \strlen($item['hint']) + $spacingForTag, $spacingForTag)
  309. .$item['var']
  310. .(
  311. '' !== $item['desc']
  312. ? $this->getIndent($varMax - \strlen($item['var']) + $spacingForTag, $spacingForTag).$item['desc']
  313. : ''
  314. );
  315. } elseif ('' !== $item['desc']) {
  316. $line .= $this->getIndent($hintMax - \strlen($item['hint']) + $spacingForTag, $spacingForTag).$item['desc'];
  317. }
  318. $docBlock->getLine($current + $j)->setContent($line.$lineEnding);
  319. }
  320. }
  321. }
  322. private function spacingForTag(?string $tag): int
  323. {
  324. return (\is_int($this->spacing))
  325. ? $this->spacing
  326. : ($this->spacing[$tag] ?? $this->spacing[self::DEFAULT_SPACING_KEY] ?? self::DEFAULT_SPACING);
  327. }
  328. /**
  329. * @TODO Introduce proper DTO instead of an array
  330. *
  331. * @return null|array{indent: null|string, tag: null|string, hint: string, var: null|string, static: string, desc?: null|string}
  332. */
  333. private function getMatches(string $line, bool $matchCommentOnly = false): ?array
  334. {
  335. if (Preg::match($this->regex, $line, $matches)) {
  336. if (isset($matches['tag2']) && '' !== $matches['tag2']) {
  337. $matches['tag'] = $matches['tag2'];
  338. $matches['hint'] = $matches['hint2'];
  339. $matches['var'] = '';
  340. }
  341. if (isset($matches['tag3']) && '' !== $matches['tag3']) {
  342. $matches['tag'] = $matches['tag3'];
  343. $matches['hint'] = $matches['hint3'];
  344. $matches['var'] = $matches['signature'];
  345. // Since static can be both a return type declaration & a keyword that defines static methods
  346. // we assume it's a type declaration when only one value is present
  347. if ('' === $matches['hint'] && '' !== $matches['static']) {
  348. $matches['hint'] = $matches['static'];
  349. $matches['static'] = '';
  350. }
  351. }
  352. if (isset($matches['hint'])) {
  353. $matches['hint'] = trim($matches['hint']);
  354. }
  355. if (!isset($matches['static'])) {
  356. $matches['static'] = '';
  357. }
  358. return $matches;
  359. }
  360. if ($matchCommentOnly && Preg::match($this->regexCommentLine, $line, $matches)) {
  361. $matches['tag'] = null;
  362. $matches['var'] = '';
  363. $matches['hint'] = '';
  364. $matches['static'] = '';
  365. return $matches;
  366. }
  367. return null;
  368. }
  369. private function getIndent(int $verticalAlignIndent, int $leftAlignIndent = 1): string
  370. {
  371. $indent = self::ALIGN_VERTICAL === $this->align ? $verticalAlignIndent : $leftAlignIndent;
  372. return str_repeat(' ', $indent);
  373. }
  374. /**
  375. * @param non-empty-list<array{indent: null|string, tag: null|string, hint: string, var: null|string, static: string, desc?: null|string}> $items
  376. */
  377. private function getLeftAlignedDescriptionIndent(array $items, int $index): int
  378. {
  379. if (self::ALIGN_LEFT !== $this->align) {
  380. return 0;
  381. }
  382. // Find last tagged line:
  383. $item = null;
  384. for (; $index >= 0; --$index) {
  385. $item = $items[$index];
  386. if (null !== $item['tag']) {
  387. break;
  388. }
  389. }
  390. // No last tag found — no indent:
  391. if (null === $item) {
  392. return 0;
  393. }
  394. $spacingForTag = $this->spacingForTag($item['tag']);
  395. // Indent according to existing values:
  396. return
  397. $this->getSentenceIndent($item['static'], $spacingForTag) +
  398. $this->getSentenceIndent($item['tag'], $spacingForTag) +
  399. $this->getSentenceIndent($item['hint'], $spacingForTag) +
  400. $this->getSentenceIndent($item['var'], $spacingForTag);
  401. }
  402. /**
  403. * Get indent for sentence.
  404. */
  405. private function getSentenceIndent(?string $sentence, int $spacingForTag = 1): int
  406. {
  407. if (null === $sentence) {
  408. return 0;
  409. }
  410. $length = \strlen($sentence);
  411. return 0 === $length ? 0 : $length + $spacingForTag;
  412. }
  413. }