OrderedAttributesFixer.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  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\AttributeNotation;
  13. use PhpCsFixer\AbstractFixer;
  14. use PhpCsFixer\ConfigurationException\InvalidFixerConfigurationException;
  15. use PhpCsFixer\Fixer\ConfigurableFixerInterface;
  16. use PhpCsFixer\Fixer\ConfigurableFixerTrait;
  17. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
  18. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
  19. use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
  20. use PhpCsFixer\FixerDefinition\FixerDefinition;
  21. use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
  22. use PhpCsFixer\FixerDefinition\VersionSpecification;
  23. use PhpCsFixer\FixerDefinition\VersionSpecificCodeSample;
  24. use PhpCsFixer\Tokenizer\Analyzer\Analysis\AttributeAnalysis;
  25. use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
  26. use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;
  27. use PhpCsFixer\Tokenizer\Analyzer\AttributeAnalyzer;
  28. use PhpCsFixer\Tokenizer\Analyzer\NamespacesAnalyzer;
  29. use PhpCsFixer\Tokenizer\Analyzer\NamespaceUsesAnalyzer;
  30. use PhpCsFixer\Tokenizer\Token;
  31. use PhpCsFixer\Tokenizer\Tokens;
  32. use Symfony\Component\OptionsResolver\Options;
  33. /**
  34. * @author HypeMC <hypemc@gmail.com>
  35. *
  36. * @phpstan-import-type _AttributeItems from AttributeAnalysis
  37. *
  38. * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
  39. *
  40. * @phpstan-type _AutogeneratedInputConfiguration array{
  41. * order?: list<string>,
  42. * sort_algorithm?: 'alpha'|'custom'
  43. * }
  44. * @phpstan-type _AutogeneratedComputedConfiguration array{
  45. * order: array<string, int>,
  46. * sort_algorithm: 'alpha'|'custom'
  47. * }
  48. */
  49. final class OrderedAttributesFixer extends AbstractFixer implements ConfigurableFixerInterface
  50. {
  51. /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
  52. use ConfigurableFixerTrait;
  53. public const ORDER_ALPHA = 'alpha';
  54. public const ORDER_CUSTOM = 'custom';
  55. private const SUPPORTED_SORT_ALGORITHMS = [
  56. self::ORDER_ALPHA,
  57. self::ORDER_CUSTOM,
  58. ];
  59. public function getDefinition(): FixerDefinitionInterface
  60. {
  61. return new FixerDefinition(
  62. 'Sorts attributes using the configured sort algorithm.',
  63. [
  64. new VersionSpecificCodeSample(
  65. <<<'EOL'
  66. <?php
  67. #[Foo]
  68. #[Bar(3)]
  69. #[Qux(new Bar(5))]
  70. #[Corge(a: 'test')]
  71. class Sample1 {}
  72. #[
  73. Foo,
  74. Bar(3),
  75. Qux(new Bar(5)),
  76. Corge(a: 'test'),
  77. ]
  78. class Sample2 {}
  79. EOL,
  80. new VersionSpecification(8_00_00),
  81. ),
  82. new VersionSpecificCodeSample(
  83. <<<'EOL'
  84. <?php
  85. use A\B\Foo;
  86. use A\B\Bar as BarAlias;
  87. use A\B as AB;
  88. #[Foo]
  89. #[BarAlias(3)]
  90. #[AB\Qux(new Bar(5))]
  91. #[\A\B\Corge(a: 'test')]
  92. class Sample1 {}
  93. EOL,
  94. new VersionSpecification(8_00_00),
  95. ['sort_algorithm' => self::ORDER_CUSTOM, 'order' => ['A\B\Qux', 'A\B\Bar', 'A\B\Corge']],
  96. ),
  97. ],
  98. );
  99. }
  100. /**
  101. * {@inheritdoc}
  102. *
  103. * Must run after FullyQualifiedStrictTypesFixer.
  104. */
  105. public function getPriority(): int
  106. {
  107. return 0;
  108. }
  109. public function isCandidate(Tokens $tokens): bool
  110. {
  111. return \defined('T_ATTRIBUTE') && $tokens->isTokenKindFound(T_ATTRIBUTE);
  112. }
  113. protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
  114. {
  115. $fixerName = $this->getName();
  116. return new FixerConfigurationResolver([
  117. (new FixerOptionBuilder('sort_algorithm', 'How the attributes should be sorted.'))
  118. ->setAllowedValues(self::SUPPORTED_SORT_ALGORITHMS)
  119. ->setDefault(self::ORDER_ALPHA)
  120. ->setNormalizer(static function (Options $options, string $value) use ($fixerName): string {
  121. if (self::ORDER_CUSTOM === $value && [] === $options['order']) {
  122. throw new InvalidFixerConfigurationException(
  123. $fixerName,
  124. 'The custom order strategy requires providing `order` option with a list of attributes\'s FQNs.'
  125. );
  126. }
  127. return $value;
  128. })
  129. ->getOption(),
  130. (new FixerOptionBuilder('order', 'A list of FQCNs of attributes defining the desired order used when custom sorting algorithm is configured.'))
  131. ->setAllowedTypes(['string[]'])
  132. ->setDefault([])
  133. ->setNormalizer(static function (Options $options, array $value) use ($fixerName): array {
  134. if ($value !== array_unique($value)) {
  135. throw new InvalidFixerConfigurationException($fixerName, 'The list includes attributes that are not unique.');
  136. }
  137. return array_flip(array_values(
  138. array_map(static fn (string $attribute): string => ltrim($attribute, '\\'), $value),
  139. ));
  140. })
  141. ->getOption(),
  142. ]);
  143. }
  144. protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
  145. {
  146. $index = 0;
  147. while (null !== $index = $tokens->getNextTokenOfKind($index, [[T_ATTRIBUTE]])) {
  148. /** @var list<array{name: string, start: int, end: int}> $elements */
  149. $elements = array_map(function (AttributeAnalysis $attributeAnalysis) use ($tokens): array {
  150. return [
  151. 'name' => $this->sortAttributes($tokens, $attributeAnalysis->getStartIndex(), $attributeAnalysis->getAttributes()),
  152. 'start' => $attributeAnalysis->getStartIndex(),
  153. 'end' => $attributeAnalysis->getEndIndex(),
  154. ];
  155. }, AttributeAnalyzer::collect($tokens, $index));
  156. $endIndex = end($elements)['end'];
  157. try {
  158. if (1 === \count($elements)) {
  159. continue;
  160. }
  161. $sortedElements = $this->sortElements($elements);
  162. if ($elements === $sortedElements) {
  163. continue;
  164. }
  165. $this->sortTokens($tokens, $index, $endIndex, $sortedElements);
  166. } finally {
  167. $index = $endIndex;
  168. }
  169. }
  170. }
  171. /**
  172. * @param _AttributeItems $attributes
  173. */
  174. private function sortAttributes(Tokens $tokens, int $index, array $attributes): string
  175. {
  176. if (1 === \count($attributes)) {
  177. return $this->getAttributeName($tokens, $attributes[0]['name'], $attributes[0]['start']);
  178. }
  179. foreach ($attributes as &$attribute) {
  180. $attribute['name'] = $this->getAttributeName($tokens, $attribute['name'], $attribute['start']);
  181. }
  182. $sortedElements = $this->sortElements($attributes);
  183. if ($attributes === $sortedElements) {
  184. return $attributes[0]['name'];
  185. }
  186. $this->sortTokens($tokens, $index + 1, end($attributes)['end'], $sortedElements, new Token(','));
  187. return $sortedElements[0]['name'];
  188. }
  189. private function getAttributeName(Tokens $tokens, string $name, int $index): string
  190. {
  191. if (self::ORDER_CUSTOM === $this->configuration['sort_algorithm']) {
  192. $name = $this->determineAttributeFullyQualifiedName($tokens, $name, $index);
  193. }
  194. return ltrim($name, '\\');
  195. }
  196. private function determineAttributeFullyQualifiedName(Tokens $tokens, string $name, int $index): string
  197. {
  198. if ('\\' === $name[0]) {
  199. return $name;
  200. }
  201. if (!$tokens[$index]->isGivenKind([T_STRING, T_NS_SEPARATOR])) {
  202. $index = $tokens->getNextTokenOfKind($index, [[T_STRING], [T_NS_SEPARATOR]]);
  203. }
  204. [$namespaceAnalysis, $namespaceUseAnalyses] = $this->collectNamespaceAnalysis($tokens, $index);
  205. $namespace = $namespaceAnalysis->getFullName();
  206. $firstTokenOfName = $tokens[$index]->getContent();
  207. $namespaceUseAnalysis = $namespaceUseAnalyses[$firstTokenOfName] ?? false;
  208. if ($namespaceUseAnalysis instanceof NamespaceUseAnalysis) {
  209. $namespace = $namespaceUseAnalysis->getFullName();
  210. if ($name === $firstTokenOfName) {
  211. return $namespace;
  212. }
  213. $name = substr(strstr($name, '\\'), 1);
  214. }
  215. return $namespace.'\\'.$name;
  216. }
  217. /**
  218. * @param list<array{name: string, start: int, end: int}> $elements
  219. *
  220. * @return list<array{name: string, start: int, end: int}>
  221. */
  222. private function sortElements(array $elements): array
  223. {
  224. usort($elements, function (array $a, array $b): int {
  225. $sortAlgorithm = $this->configuration['sort_algorithm'];
  226. if (self::ORDER_ALPHA === $sortAlgorithm) {
  227. return $a['name'] <=> $b['name'];
  228. }
  229. if (self::ORDER_CUSTOM === $sortAlgorithm) {
  230. return
  231. ($this->configuration['order'][$a['name']] ?? PHP_INT_MAX)
  232. <=>
  233. ($this->configuration['order'][$b['name']] ?? PHP_INT_MAX);
  234. }
  235. throw new \InvalidArgumentException(\sprintf('Invalid sort algorithm "%s" provided.', $sortAlgorithm));
  236. });
  237. return $elements;
  238. }
  239. /**
  240. * @param list<array{name: string, start: int, end: int}> $elements
  241. */
  242. private function sortTokens(Tokens $tokens, int $startIndex, int $endIndex, array $elements, ?Token $delimiter = null): void
  243. {
  244. $replaceTokens = [];
  245. foreach ($elements as $pos => $element) {
  246. for ($i = $element['start']; $i <= $element['end']; ++$i) {
  247. $replaceTokens[] = clone $tokens[$i];
  248. }
  249. if (null !== $delimiter && $pos !== \count($elements) - 1) {
  250. $replaceTokens[] = clone $delimiter;
  251. }
  252. }
  253. $tokens->overrideRange($startIndex, $endIndex, $replaceTokens);
  254. }
  255. /**
  256. * @return array{NamespaceAnalysis, array<string, NamespaceUseAnalysis>}
  257. */
  258. private function collectNamespaceAnalysis(Tokens $tokens, int $startIndex): array
  259. {
  260. $namespaceAnalysis = (new NamespacesAnalyzer())->getNamespaceAt($tokens, $startIndex);
  261. $namespaceUseAnalyses = (new NamespaceUsesAnalyzer())->getDeclarationsInNamespace($tokens, $namespaceAnalysis);
  262. $uses = [];
  263. foreach ($namespaceUseAnalyses as $use) {
  264. if (!$use->isClass()) {
  265. continue;
  266. }
  267. $uses[$use->getShortName()] = $use;
  268. }
  269. return [$namespaceAnalysis, $uses];
  270. }
  271. }