FinalInternalClassFixer.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  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\ConfigurationException\InvalidFixerConfigurationException;
  15. use PhpCsFixer\DocBlock\DocBlock;
  16. use PhpCsFixer\Fixer\ConfigurableFixerInterface;
  17. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
  18. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
  19. use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
  20. use PhpCsFixer\FixerDefinition\CodeSample;
  21. use PhpCsFixer\FixerDefinition\FixerDefinition;
  22. use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
  23. use PhpCsFixer\Preg;
  24. use PhpCsFixer\Tokenizer\CT;
  25. use PhpCsFixer\Tokenizer\Token;
  26. use PhpCsFixer\Tokenizer\Tokens;
  27. use PhpCsFixer\Tokenizer\TokensAnalyzer;
  28. use PhpCsFixer\Utils;
  29. use Symfony\Component\OptionsResolver\Options;
  30. /**
  31. * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
  32. */
  33. final class FinalInternalClassFixer extends AbstractFixer implements ConfigurableFixerInterface
  34. {
  35. private const DEFAULTS = [
  36. 'include' => [
  37. 'internal',
  38. ],
  39. 'exclude' => [
  40. 'final',
  41. 'Entity',
  42. 'ORM\Entity',
  43. 'ORM\Mapping\Entity',
  44. 'Mapping\Entity',
  45. 'Document',
  46. 'ODM\Document',
  47. ],
  48. ];
  49. private bool $checkAttributes;
  50. public function __construct()
  51. {
  52. parent::__construct();
  53. $this->checkAttributes = \PHP_VERSION_ID >= 8_00_00;
  54. }
  55. public function configure(array $configuration): void
  56. {
  57. parent::configure($configuration);
  58. $this->assertConfigHasNoConflicts();
  59. }
  60. public function getDefinition(): FixerDefinitionInterface
  61. {
  62. return new FixerDefinition(
  63. 'Internal classes should be `final`.',
  64. [
  65. new CodeSample("<?php\n/**\n * @internal\n */\nclass Sample\n{\n}\n"),
  66. new CodeSample(
  67. "<?php\n/**\n * @CUSTOM\n */\nclass A{}\n\n/**\n * @CUSTOM\n * @not-fix\n */\nclass B{}\n",
  68. [
  69. 'include' => ['@Custom'],
  70. 'exclude' => ['@not-fix'],
  71. ]
  72. ),
  73. ],
  74. null,
  75. 'Changing classes to `final` might cause code execution to break.'
  76. );
  77. }
  78. /**
  79. * {@inheritdoc}
  80. *
  81. * Must run before ProtectedToPrivateFixer, SelfStaticAccessorFixer.
  82. * Must run after PhpUnitInternalClassFixer.
  83. */
  84. public function getPriority(): int
  85. {
  86. return 67;
  87. }
  88. public function isCandidate(Tokens $tokens): bool
  89. {
  90. return $tokens->isTokenKindFound(T_CLASS);
  91. }
  92. public function isRisky(): bool
  93. {
  94. return true;
  95. }
  96. protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
  97. {
  98. $tokensAnalyzer = new TokensAnalyzer($tokens);
  99. for ($index = $tokens->count() - 1; 0 <= $index; --$index) {
  100. if (!$tokens[$index]->isGivenKind(T_CLASS) || !$this->isClassCandidate($tokensAnalyzer, $tokens, $index)) {
  101. continue;
  102. }
  103. // make class 'final'
  104. $tokens->insertSlices([
  105. $index => [
  106. new Token([T_FINAL, 'final']),
  107. new Token([T_WHITESPACE, ' ']),
  108. ],
  109. ]);
  110. }
  111. }
  112. protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
  113. {
  114. $annotationsAsserts = [static function (array $values): bool {
  115. foreach ($values as $value) {
  116. if (!\is_string($value) || '' === $value) {
  117. return false;
  118. }
  119. }
  120. return true;
  121. }];
  122. $annotationsNormalizer = static function (Options $options, array $value): array {
  123. $newValue = [];
  124. foreach ($value as $key) {
  125. if (str_starts_with($key, '@')) {
  126. $key = substr($key, 1);
  127. }
  128. $newValue[strtolower($key)] = true;
  129. }
  130. return $newValue;
  131. };
  132. return new FixerConfigurationResolver([
  133. (new FixerOptionBuilder('annotation_include', 'Class level attribute or annotation tags that must be set in order to fix the class (case insensitive).'))
  134. ->setAllowedTypes(['string[]'])
  135. ->setAllowedValues($annotationsAsserts)
  136. ->setDefault(
  137. array_map(
  138. static fn (string $string) => '@'.$string,
  139. self::DEFAULTS['include'],
  140. ),
  141. )
  142. ->setNormalizer($annotationsNormalizer)
  143. ->setDeprecationMessage('Use `include` to configure PHPDoc annotations tags and attributes.')
  144. ->getOption(),
  145. (new FixerOptionBuilder('annotation_exclude', 'Class level attribute or annotation tags that must be omitted to fix the class, even if all of the white list ones are used as well (case insensitive).'))
  146. ->setAllowedTypes(['string[]'])
  147. ->setAllowedValues($annotationsAsserts)
  148. ->setDefault(
  149. array_map(
  150. static fn (string $string) => '@'.$string,
  151. self::DEFAULTS['exclude'],
  152. ),
  153. )
  154. ->setNormalizer($annotationsNormalizer)
  155. ->setDeprecationMessage('Use `exclude` to configure PHPDoc annotations tags and attributes.')
  156. ->getOption(),
  157. (new FixerOptionBuilder('include', 'Class level attribute or annotation tags that must be set in order to fix the class (case insensitive).'))
  158. ->setAllowedTypes(['string[]'])
  159. ->setAllowedValues($annotationsAsserts)
  160. ->setDefault(self::DEFAULTS['include'])
  161. ->setNormalizer($annotationsNormalizer)
  162. ->getOption(),
  163. (new FixerOptionBuilder('exclude', 'Class level attribute or annotation tags that must be omitted to fix the class, even if all of the white list ones are used as well (case insensitive).'))
  164. ->setAllowedTypes(['string[]'])
  165. ->setAllowedValues($annotationsAsserts)
  166. ->setDefault(self::DEFAULTS['exclude'])
  167. ->setNormalizer($annotationsNormalizer)
  168. ->getOption(),
  169. (new FixerOptionBuilder('consider_absent_docblock_as_internal_class', 'Whether classes without any DocBlock should be fixed to final.'))
  170. ->setAllowedTypes(['bool'])
  171. ->setDefault(false)
  172. ->getOption(),
  173. ]);
  174. }
  175. /**
  176. * @param int $index T_CLASS index
  177. */
  178. private function isClassCandidate(TokensAnalyzer $tokensAnalyzer, Tokens $tokens, int $index): bool
  179. {
  180. if ($tokensAnalyzer->isAnonymousClass($index)) {
  181. return false;
  182. }
  183. $modifiers = $tokensAnalyzer->getClassyModifiers($index);
  184. if (isset($modifiers['final']) || isset($modifiers['abstract'])) {
  185. return false; // ignore class; it is abstract or already final
  186. }
  187. $decisions = [];
  188. $currentIndex = $index;
  189. $acceptTypes = [
  190. CT::T_ATTRIBUTE_CLOSE,
  191. T_DOC_COMMENT,
  192. T_COMMENT, // Skip comments
  193. ];
  194. if (\defined('T_READONLY')) {
  195. // Skip readonly classes for PHP 8.2+
  196. $acceptTypes[] = T_READONLY;
  197. }
  198. while ($currentIndex) {
  199. $currentIndex = $tokens->getPrevNonWhitespace($currentIndex);
  200. if (!$tokens[$currentIndex]->isGivenKind($acceptTypes)) {
  201. break;
  202. }
  203. if ($this->checkAttributes && $tokens[$currentIndex]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
  204. $attributeStartIndex = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ATTRIBUTE, $currentIndex);
  205. $decisions[] = $this->isClassCandidateBasedOnAttribute($tokens, $attributeStartIndex, $currentIndex);
  206. $currentIndex = $attributeStartIndex;
  207. }
  208. if ($tokens[$currentIndex]->isGivenKind([T_DOC_COMMENT])) {
  209. $decisions[] = $this->isClassCandidateBasedOnPhpDoc($tokens, $currentIndex);
  210. }
  211. }
  212. if (\in_array(false, $decisions, true)) {
  213. return false;
  214. }
  215. return \in_array(true, $decisions, true)
  216. || ([] === $decisions && true === $this->configuration['consider_absent_docblock_as_internal_class']);
  217. }
  218. private function isClassCandidateBasedOnPhpDoc(Tokens $tokens, int $index): ?bool
  219. {
  220. $doc = new DocBlock($tokens[$index]->getContent());
  221. $tags = [];
  222. foreach ($doc->getAnnotations() as $annotation) {
  223. if (!Preg::match('/@([^\(\s]+)/', $annotation->getContent(), $matches)) {
  224. continue;
  225. }
  226. $tag = strtolower(substr(array_shift($matches), 1));
  227. $tags[$tag] = true;
  228. }
  229. if (\count(array_intersect_key($this->configuration['exclude'], $tags)) > 0) {
  230. return false;
  231. }
  232. if ($this->isConfiguredAsInclude($tags)) {
  233. return true;
  234. }
  235. return null;
  236. }
  237. private function isClassCandidateBasedOnAttribute(Tokens $tokens, int $startIndex, int $endIndex): ?bool
  238. {
  239. $attributeCandidates = [];
  240. $attributeString = '';
  241. $currentIndex = $startIndex;
  242. while ($currentIndex < $endIndex && null !== ($currentIndex = $tokens->getNextMeaningfulToken($currentIndex))) {
  243. if (!$tokens[$currentIndex]->isGivenKind([T_STRING, T_NS_SEPARATOR])) {
  244. if ('' !== $attributeString) {
  245. $attributeCandidates[$attributeString] = true;
  246. $attributeString = '';
  247. }
  248. continue;
  249. }
  250. $attributeString .= strtolower($tokens[$currentIndex]->getContent());
  251. }
  252. if (\count(array_intersect_key($this->configuration['exclude'], $attributeCandidates)) > 0) {
  253. return false;
  254. }
  255. if ($this->isConfiguredAsInclude($attributeCandidates)) {
  256. return true;
  257. }
  258. return null;
  259. }
  260. /**
  261. * @param array<string, bool> $attributes
  262. */
  263. private function isConfiguredAsInclude(array $attributes): bool
  264. {
  265. if (0 === \count($this->configuration['include'])) {
  266. return true;
  267. }
  268. return \count(array_intersect_key($this->configuration['include'], $attributes)) > 0;
  269. }
  270. private function assertConfigHasNoConflicts(): void
  271. {
  272. foreach (['include', 'exclude'] as $newConfigKey) {
  273. $oldConfigKey = 'annotation_'.$newConfigKey;
  274. $defaults = [];
  275. foreach (self::DEFAULTS[$newConfigKey] as $foo) {
  276. $defaults[strtolower($foo)] = true;
  277. }
  278. $newConfigIsSet = $this->configuration[$newConfigKey] !== $defaults;
  279. $oldConfigIsSet = $this->configuration[$oldConfigKey] !== $defaults;
  280. if ($newConfigIsSet && $oldConfigIsSet) {
  281. throw new InvalidFixerConfigurationException($this->getName(), sprintf('Configuration cannot contain deprecated option "%s" and new option "%s".', $oldConfigKey, $newConfigKey));
  282. }
  283. if ($oldConfigIsSet) {
  284. $this->configuration[$newConfigKey] = $this->configuration[$oldConfigKey];
  285. $this->checkAttributes = false; // run in old mode
  286. }
  287. // if ($newConfigIsSet) - only new config is set, all good
  288. // if (!$newConfigIsSet && !$oldConfigIsSet) - both are set as to default values, all good
  289. unset($this->configuration[$oldConfigKey]);
  290. }
  291. $intersect = array_intersect_assoc($this->configuration['include'], $this->configuration['exclude']);
  292. if (\count($intersect) > 0) {
  293. throw new InvalidFixerConfigurationException($this->getName(), sprintf('Annotation cannot be used in both "include" and "exclude" list, got duplicates: %s.', Utils::naturalLanguageJoin(array_keys($intersect))));
  294. }
  295. }
  296. }