ClassAttributesSeparationFixer.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  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\ClassNotation;
  13. use PhpCsFixer\AbstractFixer;
  14. use PhpCsFixer\Fixer\ConfigurableFixerInterface;
  15. use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
  16. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
  17. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
  18. use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
  19. use PhpCsFixer\FixerDefinition\CodeSample;
  20. use PhpCsFixer\FixerDefinition\FixerDefinition;
  21. use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
  22. use PhpCsFixer\FixerDefinition\VersionSpecification;
  23. use PhpCsFixer\FixerDefinition\VersionSpecificCodeSample;
  24. use PhpCsFixer\Preg;
  25. use PhpCsFixer\Tokenizer\CT;
  26. use PhpCsFixer\Tokenizer\Token;
  27. use PhpCsFixer\Tokenizer\Tokens;
  28. use PhpCsFixer\Tokenizer\TokensAnalyzer;
  29. use PhpCsFixer\Utils;
  30. use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
  31. /**
  32. * Make sure there is one blank line above and below class elements.
  33. *
  34. * The exception is when an element is the first or last item in a 'classy'.
  35. *
  36. * @phpstan-type _Class array{
  37. * index: int,
  38. * open: int,
  39. * close: int,
  40. * elements: non-empty-list<_Element>
  41. * }
  42. * @phpstan-type _Element array{token: Token, type: string, index: int, start?: int, end?: int}
  43. */
  44. final class ClassAttributesSeparationFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
  45. {
  46. /**
  47. * @internal
  48. */
  49. public const SPACING_NONE = 'none';
  50. /**
  51. * @internal
  52. */
  53. public const SPACING_ONE = 'one';
  54. private const SPACING_ONLY_IF_META = 'only_if_meta';
  55. /**
  56. * @var array<string, string>
  57. */
  58. private array $classElementTypes = [];
  59. public function configure(array $configuration): void
  60. {
  61. parent::configure($configuration);
  62. $this->classElementTypes = []; // reset previous configuration
  63. foreach ($this->configuration['elements'] as $elementType => $spacing) {
  64. $this->classElementTypes[$elementType] = $spacing;
  65. }
  66. }
  67. public function getDefinition(): FixerDefinitionInterface
  68. {
  69. return new FixerDefinition(
  70. 'Class, trait and interface elements must be separated with one or none blank line.',
  71. [
  72. new CodeSample(
  73. '<?php
  74. final class Sample
  75. {
  76. protected function foo()
  77. {
  78. }
  79. protected function bar()
  80. {
  81. }
  82. }
  83. '
  84. ),
  85. new CodeSample(
  86. '<?php
  87. class Sample
  88. {private $a; // foo
  89. /** second in a hour */
  90. private $b;
  91. }
  92. ',
  93. ['elements' => ['property' => self::SPACING_ONE]]
  94. ),
  95. new CodeSample(
  96. '<?php
  97. class Sample
  98. {
  99. const A = 1;
  100. /** seconds in some hours */
  101. const B = 3600;
  102. }
  103. ',
  104. ['elements' => ['const' => self::SPACING_ONE]]
  105. ),
  106. new CodeSample(
  107. '<?php
  108. class Sample
  109. {
  110. /** @var int */
  111. const SECOND = 1;
  112. /** @var int */
  113. const MINUTE = 60;
  114. const HOUR = 3600;
  115. const DAY = 86400;
  116. }
  117. ',
  118. ['elements' => ['const' => self::SPACING_ONLY_IF_META]]
  119. ),
  120. new VersionSpecificCodeSample(
  121. '<?php
  122. class Sample
  123. {
  124. public $a;
  125. #[SetUp]
  126. public $b;
  127. /** @var string */
  128. public $c;
  129. /** @internal */
  130. #[Assert\String()]
  131. public $d;
  132. public $e;
  133. }
  134. ',
  135. new VersionSpecification(8_00_00),
  136. ['elements' => ['property' => self::SPACING_ONLY_IF_META]]
  137. ),
  138. ]
  139. );
  140. }
  141. /**
  142. * {@inheritdoc}
  143. *
  144. * Must run before BracesFixer, IndentationTypeFixer, NoExtraBlankLinesFixer, StatementIndentationFixer.
  145. * Must run after OrderedClassElementsFixer, SingleClassElementPerStatementFixer, VisibilityRequiredFixer.
  146. */
  147. public function getPriority(): int
  148. {
  149. return 55;
  150. }
  151. public function isCandidate(Tokens $tokens): bool
  152. {
  153. return $tokens->isAnyTokenKindsFound(Token::getClassyTokenKinds());
  154. }
  155. protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
  156. {
  157. foreach ($this->getElementsByClass($tokens) as $class) {
  158. $elements = $class['elements'];
  159. $elementCount = \count($elements);
  160. if (0 === $elementCount) {
  161. continue;
  162. }
  163. if (isset($this->classElementTypes[$elements[0]['type']])) {
  164. $this->fixSpaceBelowClassElement($tokens, $class);
  165. $this->fixSpaceAboveClassElement($tokens, $class, 0);
  166. }
  167. for ($index = 1; $index < $elementCount; ++$index) {
  168. if (isset($this->classElementTypes[$elements[$index]['type']])) {
  169. $this->fixSpaceAboveClassElement($tokens, $class, $index);
  170. }
  171. }
  172. }
  173. }
  174. protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
  175. {
  176. return new FixerConfigurationResolver([
  177. (new FixerOptionBuilder('elements', 'Dictionary of `const|method|property|trait_import|case` => `none|one|only_if_meta` values.'))
  178. ->setAllowedTypes(['string[]'])
  179. ->setAllowedValues([static function (array $option): bool {
  180. foreach ($option as $type => $spacing) {
  181. $supportedTypes = ['const', 'method', 'property', 'trait_import', 'case'];
  182. if (!\in_array($type, $supportedTypes, true)) {
  183. throw new InvalidOptionsException(
  184. sprintf(
  185. 'Unexpected element type, expected any of %s, got "%s".',
  186. Utils::naturalLanguageJoin($supportedTypes),
  187. \gettype($type).'#'.$type
  188. )
  189. );
  190. }
  191. $supportedSpacings = [self::SPACING_NONE, self::SPACING_ONE, self::SPACING_ONLY_IF_META];
  192. if (!\in_array($spacing, $supportedSpacings, true)) {
  193. throw new InvalidOptionsException(
  194. sprintf(
  195. 'Unexpected spacing for element type "%s", expected any of %s, got "%s".',
  196. $spacing,
  197. Utils::naturalLanguageJoin($supportedSpacings),
  198. \is_object($spacing) ? \get_class($spacing) : (null === $spacing ? 'null' : \gettype($spacing).'#'.$spacing)
  199. )
  200. );
  201. }
  202. }
  203. return true;
  204. }])
  205. ->setDefault([
  206. 'const' => self::SPACING_ONE,
  207. 'method' => self::SPACING_ONE,
  208. 'property' => self::SPACING_ONE,
  209. 'trait_import' => self::SPACING_NONE,
  210. 'case' => self::SPACING_NONE,
  211. ])
  212. ->getOption(),
  213. ]);
  214. }
  215. /**
  216. * Fix spacing above an element of a class, interface or trait.
  217. *
  218. * Deals with comments, PHPDocs and spaces above the element with respect to the position of the
  219. * element within the class, interface or trait.
  220. *
  221. * @param _Class $class
  222. */
  223. private function fixSpaceAboveClassElement(Tokens $tokens, array $class, int $elementIndex): void
  224. {
  225. $element = $class['elements'][$elementIndex];
  226. $elementAboveEnd = isset($class['elements'][$elementIndex + 1]) ? $class['elements'][$elementIndex + 1]['end'] : 0;
  227. $nonWhiteAbove = $tokens->getPrevNonWhitespace($element['start']);
  228. // element is directly after class open brace
  229. if ($nonWhiteAbove === $class['open']) {
  230. $this->correctLineBreaks($tokens, $nonWhiteAbove, $element['start'], 1);
  231. return;
  232. }
  233. // deal with comments above an element
  234. if ($tokens[$nonWhiteAbove]->isGivenKind(T_COMMENT)) {
  235. // check if the comment belongs to the previous element
  236. if ($elementAboveEnd === $nonWhiteAbove) {
  237. $this->correctLineBreaks($tokens, $nonWhiteAbove, $element['start'], $this->determineRequiredLineCount($tokens, $class, $elementIndex));
  238. return;
  239. }
  240. // more than one line break, always bring it back to 2 line breaks between the element start and what is above it
  241. if ($tokens[$nonWhiteAbove + 1]->isWhitespace() && substr_count($tokens[$nonWhiteAbove + 1]->getContent(), "\n") > 1) {
  242. $this->correctLineBreaks($tokens, $nonWhiteAbove, $element['start'], 2);
  243. return;
  244. }
  245. // there are 2 cases:
  246. if (
  247. 1 === $element['start'] - $nonWhiteAbove
  248. || $tokens[$nonWhiteAbove - 1]->isWhitespace() && substr_count($tokens[$nonWhiteAbove - 1]->getContent(), "\n") > 0
  249. || $tokens[$nonWhiteAbove + 1]->isWhitespace() && substr_count($tokens[$nonWhiteAbove + 1]->getContent(), "\n") > 0
  250. ) {
  251. // 1. The comment is meant for the element (although not a PHPDoc),
  252. // make sure there is one line break between the element and the comment...
  253. $this->correctLineBreaks($tokens, $nonWhiteAbove, $element['start'], 1);
  254. // ... and make sure there is blank line above the comment (with the exception when it is directly after a class opening)
  255. $nonWhiteAbove = $this->findCommentBlockStart($tokens, $nonWhiteAbove, $elementAboveEnd);
  256. $nonWhiteAboveComment = $tokens->getPrevNonWhitespace($nonWhiteAbove);
  257. $this->correctLineBreaks($tokens, $nonWhiteAboveComment, $nonWhiteAbove, $nonWhiteAboveComment === $class['open'] ? 1 : 2);
  258. } else {
  259. // 2. The comment belongs to the code above the element,
  260. // make sure there is a blank line above the element (i.e. 2 line breaks)
  261. $this->correctLineBreaks($tokens, $nonWhiteAbove, $element['start'], 2);
  262. }
  263. return;
  264. }
  265. // deal with element with a PHPDoc/attribute above it
  266. if ($tokens[$nonWhiteAbove]->isGivenKind([T_DOC_COMMENT, CT::T_ATTRIBUTE_CLOSE])) {
  267. // there should be one linebreak between the element and the attribute above it
  268. $this->correctLineBreaks($tokens, $nonWhiteAbove, $element['start'], 1);
  269. // make sure there is blank line above the comment (with the exception when it is directly after a class opening)
  270. $nonWhiteAbove = $this->findCommentBlockStart($tokens, $nonWhiteAbove, $elementAboveEnd);
  271. $nonWhiteAboveComment = $tokens->getPrevNonWhitespace($nonWhiteAbove);
  272. $this->correctLineBreaks($tokens, $nonWhiteAboveComment, $nonWhiteAbove, $nonWhiteAboveComment === $class['open'] ? 1 : 2);
  273. return;
  274. }
  275. $this->correctLineBreaks($tokens, $nonWhiteAbove, $element['start'], $this->determineRequiredLineCount($tokens, $class, $elementIndex));
  276. }
  277. /**
  278. * @param _Class $class
  279. */
  280. private function determineRequiredLineCount(Tokens $tokens, array $class, int $elementIndex): int
  281. {
  282. $type = $class['elements'][$elementIndex]['type'];
  283. $spacing = $this->classElementTypes[$type];
  284. if (self::SPACING_ONE === $spacing) {
  285. return 2;
  286. }
  287. if (self::SPACING_NONE === $spacing) {
  288. if (!isset($class['elements'][$elementIndex + 1])) {
  289. return 1;
  290. }
  291. $aboveElement = $class['elements'][$elementIndex + 1];
  292. if ($aboveElement['type'] !== $type) {
  293. return 2;
  294. }
  295. $aboveElementDocCandidateIndex = $tokens->getPrevNonWhitespace($aboveElement['start']);
  296. return $tokens[$aboveElementDocCandidateIndex]->isGivenKind([T_DOC_COMMENT, CT::T_ATTRIBUTE_CLOSE]) ? 2 : 1;
  297. }
  298. if (self::SPACING_ONLY_IF_META === $spacing) {
  299. $aboveElementDocCandidateIndex = $tokens->getPrevNonWhitespace($class['elements'][$elementIndex]['start']);
  300. return $tokens[$aboveElementDocCandidateIndex]->isGivenKind([T_DOC_COMMENT, CT::T_ATTRIBUTE_CLOSE]) ? 2 : 1;
  301. }
  302. throw new \RuntimeException(sprintf('Unknown spacing "%s".', $spacing));
  303. }
  304. /**
  305. * @param _Class $class
  306. */
  307. private function fixSpaceBelowClassElement(Tokens $tokens, array $class): void
  308. {
  309. $element = $class['elements'][0];
  310. // if this is last element fix; fix to the class end `}` here if appropriate
  311. if ($class['close'] === $tokens->getNextNonWhitespace($element['end'])) {
  312. $this->correctLineBreaks($tokens, $element['end'], $class['close'], 1);
  313. }
  314. }
  315. private function correctLineBreaks(Tokens $tokens, int $startIndex, int $endIndex, int $reqLineCount): void
  316. {
  317. $lineEnding = $this->whitespacesConfig->getLineEnding();
  318. ++$startIndex;
  319. $numbOfWhiteTokens = $endIndex - $startIndex;
  320. if (0 === $numbOfWhiteTokens) {
  321. $tokens->insertAt($startIndex, new Token([T_WHITESPACE, str_repeat($lineEnding, $reqLineCount)]));
  322. return;
  323. }
  324. $lineBreakCount = $this->getLineBreakCount($tokens, $startIndex, $endIndex);
  325. if ($reqLineCount === $lineBreakCount) {
  326. return;
  327. }
  328. if ($lineBreakCount < $reqLineCount) {
  329. $tokens[$startIndex] = new Token([
  330. T_WHITESPACE,
  331. str_repeat($lineEnding, $reqLineCount - $lineBreakCount).$tokens[$startIndex]->getContent(),
  332. ]);
  333. return;
  334. }
  335. // $lineCount = > $reqLineCount : check the one Token case first since this one will be true most of the time
  336. if (1 === $numbOfWhiteTokens) {
  337. $tokens[$startIndex] = new Token([
  338. T_WHITESPACE,
  339. Preg::replace('/\r\n|\n/', '', $tokens[$startIndex]->getContent(), $lineBreakCount - $reqLineCount),
  340. ]);
  341. return;
  342. }
  343. // $numbOfWhiteTokens = > 1
  344. $toReplaceCount = $lineBreakCount - $reqLineCount;
  345. for ($i = $startIndex; $i < $endIndex && $toReplaceCount > 0; ++$i) {
  346. $tokenLineCount = substr_count($tokens[$i]->getContent(), "\n");
  347. if ($tokenLineCount > 0) {
  348. $tokens[$i] = new Token([
  349. T_WHITESPACE,
  350. Preg::replace('/\r\n|\n/', '', $tokens[$i]->getContent(), min($toReplaceCount, $tokenLineCount)),
  351. ]);
  352. $toReplaceCount -= $tokenLineCount;
  353. }
  354. }
  355. }
  356. private function getLineBreakCount(Tokens $tokens, int $startIndex, int $endIndex): int
  357. {
  358. $lineCount = 0;
  359. for ($i = $startIndex; $i < $endIndex; ++$i) {
  360. $lineCount += substr_count($tokens[$i]->getContent(), "\n");
  361. }
  362. return $lineCount;
  363. }
  364. private function findCommentBlockStart(Tokens $tokens, int $start, int $elementAboveEnd): int
  365. {
  366. for ($i = $start; $i > $elementAboveEnd; --$i) {
  367. if ($tokens[$i]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
  368. $start = $i = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ATTRIBUTE, $i);
  369. continue;
  370. }
  371. if ($tokens[$i]->isComment()) {
  372. $start = $i;
  373. continue;
  374. }
  375. if (!$tokens[$i]->isWhitespace() || $this->getLineBreakCount($tokens, $i, $i + 1) > 1) {
  376. break;
  377. }
  378. }
  379. return $start;
  380. }
  381. /**
  382. * @TODO Introduce proper DTO instead of an array
  383. *
  384. * @return \Generator<_Class>
  385. */
  386. private function getElementsByClass(Tokens $tokens): \Generator
  387. {
  388. $tokensAnalyzer = new TokensAnalyzer($tokens);
  389. $class = $classIndex = false;
  390. $elements = $tokensAnalyzer->getClassyElements();
  391. for (end($elements);; prev($elements)) {
  392. $index = key($elements);
  393. if (null === $index) {
  394. break;
  395. }
  396. $element = current($elements);
  397. $element['index'] = $index;
  398. if ($element['classIndex'] !== $classIndex) {
  399. if (false !== $class) {
  400. yield $class;
  401. }
  402. $classIndex = $element['classIndex'];
  403. $classOpen = $tokens->getNextTokenOfKind($classIndex, ['{']);
  404. $classEnd = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $classOpen);
  405. $class = [
  406. 'index' => $classIndex,
  407. 'open' => $classOpen,
  408. 'close' => $classEnd,
  409. 'elements' => [],
  410. ];
  411. }
  412. unset($element['classIndex']);
  413. $element['start'] = $this->getFirstTokenIndexOfClassElement($tokens, $class, $element);
  414. $element['end'] = $this->getLastTokenIndexOfClassElement($tokens, $class, $element, $tokensAnalyzer);
  415. $class['elements'][] = $element; // reset the key by design
  416. }
  417. if (false !== $class) {
  418. yield $class;
  419. }
  420. }
  421. /**
  422. * including trailing single line comments if belonging to the class element.
  423. *
  424. * @param _Class $class
  425. * @param _Element $element
  426. */
  427. private function getFirstTokenIndexOfClassElement(Tokens $tokens, array $class, array $element): int
  428. {
  429. $modifierTypes = [T_PRIVATE, T_PROTECTED, T_PUBLIC, T_ABSTRACT, T_FINAL, T_STATIC, T_STRING, T_NS_SEPARATOR, T_VAR, CT::T_NULLABLE_TYPE, CT::T_ARRAY_TYPEHINT, CT::T_TYPE_ALTERNATION, CT::T_TYPE_INTERSECTION, CT::T_DISJUNCTIVE_NORMAL_FORM_TYPE_PARENTHESIS_OPEN, CT::T_DISJUNCTIVE_NORMAL_FORM_TYPE_PARENTHESIS_CLOSE];
  430. if (\defined('T_READONLY')) { // @TODO: drop condition when PHP 8.1+ is required
  431. $modifierTypes[] = T_READONLY;
  432. }
  433. $firstElementAttributeIndex = $element['index'];
  434. do {
  435. $nonWhiteAbove = $tokens->getPrevMeaningfulToken($firstElementAttributeIndex);
  436. if (null !== $nonWhiteAbove && $tokens[$nonWhiteAbove]->isGivenKind($modifierTypes)) {
  437. $firstElementAttributeIndex = $nonWhiteAbove;
  438. } else {
  439. break;
  440. }
  441. } while ($firstElementAttributeIndex > $class['open']);
  442. return $firstElementAttributeIndex;
  443. }
  444. /**
  445. * including trailing single line comments if belonging to the class element.
  446. *
  447. * @param _Class $class
  448. * @param _Element $element
  449. */
  450. private function getLastTokenIndexOfClassElement(Tokens $tokens, array $class, array $element, TokensAnalyzer $tokensAnalyzer): int
  451. {
  452. // find last token of the element
  453. if ('method' === $element['type'] && !$tokens[$class['index']]->isGivenKind(T_INTERFACE)) {
  454. $attributes = $tokensAnalyzer->getMethodAttributes($element['index']);
  455. if (true === $attributes['abstract']) {
  456. $elementEndIndex = $tokens->getNextTokenOfKind($element['index'], [';']);
  457. } else {
  458. $elementEndIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $tokens->getNextTokenOfKind($element['index'], ['{']));
  459. }
  460. } elseif ('trait_import' === $element['type']) {
  461. $elementEndIndex = $element['index'];
  462. do {
  463. $elementEndIndex = $tokens->getNextMeaningfulToken($elementEndIndex);
  464. } while ($tokens[$elementEndIndex]->isGivenKind([T_STRING, T_NS_SEPARATOR]) || $tokens[$elementEndIndex]->equals(','));
  465. if (!$tokens[$elementEndIndex]->equals(';')) {
  466. $elementEndIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $tokens->getNextTokenOfKind($element['index'], ['{']));
  467. }
  468. } else { // 'const', 'property', enum-'case', or 'method' of an interface
  469. $elementEndIndex = $tokens->getNextTokenOfKind($element['index'], [';']);
  470. }
  471. $singleLineElement = true;
  472. for ($i = $element['index'] + 1; $i < $elementEndIndex; ++$i) {
  473. if (str_contains($tokens[$i]->getContent(), "\n")) {
  474. $singleLineElement = false;
  475. break;
  476. }
  477. }
  478. if ($singleLineElement) {
  479. while (true) {
  480. $nextToken = $tokens[$elementEndIndex + 1];
  481. if (($nextToken->isComment() || $nextToken->isWhitespace()) && !str_contains($nextToken->getContent(), "\n")) {
  482. ++$elementEndIndex;
  483. } else {
  484. break;
  485. }
  486. }
  487. if ($tokens[$elementEndIndex]->isWhitespace()) {
  488. $elementEndIndex = $tokens->getPrevNonWhitespace($elementEndIndex);
  489. }
  490. }
  491. return $elementEndIndex;
  492. }
  493. }