| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- <?php
- declare(strict_types=1);
- /*
- * This file is part of PHP CS Fixer.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- * Dariusz Rumiński <dariusz.ruminski@gmail.com>
- *
- * This source file is subject to the MIT license that is bundled
- * with this source code in the file LICENSE.
- */
- namespace PhpCsFixer\Fixer\ClassNotation;
- use PhpCsFixer\AbstractFixer;
- use PhpCsFixer\ConfigurationException\InvalidFixerConfigurationException;
- use PhpCsFixer\DocBlock\DocBlock;
- use PhpCsFixer\Fixer\ConfigurableFixerInterface;
- 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\CT;
- use PhpCsFixer\Tokenizer\Token;
- use PhpCsFixer\Tokenizer\Tokens;
- use PhpCsFixer\Tokenizer\TokensAnalyzer;
- use PhpCsFixer\Utils;
- use Symfony\Component\OptionsResolver\Options;
- /**
- * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
- */
- final class FinalInternalClassFixer extends AbstractFixer implements ConfigurableFixerInterface
- {
- private const DEFAULTS = [
- 'include' => [
- 'internal',
- ],
- 'exclude' => [
- 'final',
- 'Entity',
- 'ORM\Entity',
- 'ORM\Mapping\Entity',
- 'Mapping\Entity',
- 'Document',
- 'ODM\Document',
- ],
- ];
- private bool $checkAttributes;
- public function __construct()
- {
- parent::__construct();
- $this->checkAttributes = \PHP_VERSION_ID >= 8_00_00;
- }
- public function configure(array $configuration): void
- {
- parent::configure($configuration);
- $this->assertConfigHasNoConflicts();
- }
- public function getDefinition(): FixerDefinitionInterface
- {
- return new FixerDefinition(
- 'Internal classes should be `final`.',
- [
- new CodeSample("<?php\n/**\n * @internal\n */\nclass Sample\n{\n}\n"),
- new CodeSample(
- "<?php\n/**\n * @CUSTOM\n */\nclass A{}\n\n/**\n * @CUSTOM\n * @not-fix\n */\nclass B{}\n",
- [
- 'include' => ['@Custom'],
- 'exclude' => ['@not-fix'],
- ]
- ),
- ],
- null,
- 'Changing classes to `final` might cause code execution to break.'
- );
- }
- /**
- * {@inheritdoc}
- *
- * Must run before ProtectedToPrivateFixer, SelfStaticAccessorFixer.
- * Must run after PhpUnitInternalClassFixer.
- */
- public function getPriority(): int
- {
- return 67;
- }
- public function isCandidate(Tokens $tokens): bool
- {
- return $tokens->isTokenKindFound(T_CLASS);
- }
- public function isRisky(): bool
- {
- return true;
- }
- protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
- {
- $tokensAnalyzer = new TokensAnalyzer($tokens);
- for ($index = $tokens->count() - 1; 0 <= $index; --$index) {
- if (!$tokens[$index]->isGivenKind(T_CLASS) || !$this->isClassCandidate($tokensAnalyzer, $tokens, $index)) {
- continue;
- }
- // make class 'final'
- $tokens->insertSlices([
- $index => [
- new Token([T_FINAL, 'final']),
- new Token([T_WHITESPACE, ' ']),
- ],
- ]);
- }
- }
- protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
- {
- $annotationsAsserts = [static function (array $values): bool {
- foreach ($values as $value) {
- if (!\is_string($value) || '' === $value) {
- return false;
- }
- }
- return true;
- }];
- $annotationsNormalizer = static function (Options $options, array $value): array {
- $newValue = [];
- foreach ($value as $key) {
- if (str_starts_with($key, '@')) {
- $key = substr($key, 1);
- }
- $newValue[strtolower($key)] = true;
- }
- return $newValue;
- };
- return new FixerConfigurationResolver([
- (new FixerOptionBuilder('annotation_include', 'Class level attribute or annotation tags that must be set in order to fix the class (case insensitive).'))
- ->setAllowedTypes(['string[]'])
- ->setAllowedValues($annotationsAsserts)
- ->setDefault(
- array_map(
- static fn (string $string) => '@'.$string,
- self::DEFAULTS['include'],
- ),
- )
- ->setNormalizer($annotationsNormalizer)
- ->setDeprecationMessage('Use `include` to configure PHPDoc annotations tags and attributes.')
- ->getOption(),
- (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).'))
- ->setAllowedTypes(['string[]'])
- ->setAllowedValues($annotationsAsserts)
- ->setDefault(
- array_map(
- static fn (string $string) => '@'.$string,
- self::DEFAULTS['exclude'],
- ),
- )
- ->setNormalizer($annotationsNormalizer)
- ->setDeprecationMessage('Use `exclude` to configure PHPDoc annotations tags and attributes.')
- ->getOption(),
- (new FixerOptionBuilder('include', 'Class level attribute or annotation tags that must be set in order to fix the class (case insensitive).'))
- ->setAllowedTypes(['string[]'])
- ->setAllowedValues($annotationsAsserts)
- ->setDefault(self::DEFAULTS['include'])
- ->setNormalizer($annotationsNormalizer)
- ->getOption(),
- (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).'))
- ->setAllowedTypes(['string[]'])
- ->setAllowedValues($annotationsAsserts)
- ->setDefault(self::DEFAULTS['exclude'])
- ->setNormalizer($annotationsNormalizer)
- ->getOption(),
- (new FixerOptionBuilder('consider_absent_docblock_as_internal_class', 'Whether classes without any DocBlock should be fixed to final.'))
- ->setAllowedTypes(['bool'])
- ->setDefault(false)
- ->getOption(),
- ]);
- }
- /**
- * @param int $index T_CLASS index
- */
- private function isClassCandidate(TokensAnalyzer $tokensAnalyzer, Tokens $tokens, int $index): bool
- {
- if ($tokensAnalyzer->isAnonymousClass($index)) {
- return false;
- }
- $modifiers = $tokensAnalyzer->getClassyModifiers($index);
- if (isset($modifiers['final']) || isset($modifiers['abstract'])) {
- return false; // ignore class; it is abstract or already final
- }
- $decisions = [];
- $currentIndex = $index;
- $acceptTypes = [
- CT::T_ATTRIBUTE_CLOSE,
- T_DOC_COMMENT,
- T_COMMENT, // Skip comments
- ];
- if (\defined('T_READONLY')) {
- // Skip readonly classes for PHP 8.2+
- $acceptTypes[] = T_READONLY;
- }
- while ($currentIndex) {
- $currentIndex = $tokens->getPrevNonWhitespace($currentIndex);
- if (!$tokens[$currentIndex]->isGivenKind($acceptTypes)) {
- break;
- }
- if ($this->checkAttributes && $tokens[$currentIndex]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
- $attributeStartIndex = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ATTRIBUTE, $currentIndex);
- $decisions[] = $this->isClassCandidateBasedOnAttribute($tokens, $attributeStartIndex, $currentIndex);
- $currentIndex = $attributeStartIndex;
- }
- if ($tokens[$currentIndex]->isGivenKind([T_DOC_COMMENT])) {
- $decisions[] = $this->isClassCandidateBasedOnPhpDoc($tokens, $currentIndex);
- }
- }
- if (\in_array(false, $decisions, true)) {
- return false;
- }
- return \in_array(true, $decisions, true)
- || ([] === $decisions && true === $this->configuration['consider_absent_docblock_as_internal_class']);
- }
- private function isClassCandidateBasedOnPhpDoc(Tokens $tokens, int $index): ?bool
- {
- $doc = new DocBlock($tokens[$index]->getContent());
- $tags = [];
- foreach ($doc->getAnnotations() as $annotation) {
- if (!Preg::match('/@([^\(\s]+)/', $annotation->getContent(), $matches)) {
- continue;
- }
- $tag = strtolower(substr(array_shift($matches), 1));
- $tags[$tag] = true;
- }
- if (\count(array_intersect_key($this->configuration['exclude'], $tags)) > 0) {
- return false;
- }
- if ($this->isConfiguredAsInclude($tags)) {
- return true;
- }
- return null;
- }
- private function isClassCandidateBasedOnAttribute(Tokens $tokens, int $startIndex, int $endIndex): ?bool
- {
- $attributeCandidates = [];
- $attributeString = '';
- $currentIndex = $startIndex;
- while ($currentIndex < $endIndex && null !== ($currentIndex = $tokens->getNextMeaningfulToken($currentIndex))) {
- if (!$tokens[$currentIndex]->isGivenKind([T_STRING, T_NS_SEPARATOR])) {
- if ('' !== $attributeString) {
- $attributeCandidates[$attributeString] = true;
- $attributeString = '';
- }
- continue;
- }
- $attributeString .= strtolower($tokens[$currentIndex]->getContent());
- }
- if (\count(array_intersect_key($this->configuration['exclude'], $attributeCandidates)) > 0) {
- return false;
- }
- if ($this->isConfiguredAsInclude($attributeCandidates)) {
- return true;
- }
- return null;
- }
- /**
- * @param array<string, bool> $attributes
- */
- private function isConfiguredAsInclude(array $attributes): bool
- {
- if (0 === \count($this->configuration['include'])) {
- return true;
- }
- return \count(array_intersect_key($this->configuration['include'], $attributes)) > 0;
- }
- private function assertConfigHasNoConflicts(): void
- {
- foreach (['include', 'exclude'] as $newConfigKey) {
- $oldConfigKey = 'annotation_'.$newConfigKey;
- $defaults = [];
- foreach (self::DEFAULTS[$newConfigKey] as $foo) {
- $defaults[strtolower($foo)] = true;
- }
- $newConfigIsSet = $this->configuration[$newConfigKey] !== $defaults;
- $oldConfigIsSet = $this->configuration[$oldConfigKey] !== $defaults;
- if ($newConfigIsSet && $oldConfigIsSet) {
- throw new InvalidFixerConfigurationException($this->getName(), sprintf('Configuration cannot contain deprecated option "%s" and new option "%s".', $oldConfigKey, $newConfigKey));
- }
- if ($oldConfigIsSet) {
- $this->configuration[$newConfigKey] = $this->configuration[$oldConfigKey];
- $this->checkAttributes = false; // run in old mode
- }
- // if ($newConfigIsSet) - only new config is set, all good
- // if (!$newConfigIsSet && !$oldConfigIsSet) - both are set as to default values, all good
- unset($this->configuration[$oldConfigKey]);
- }
- $intersect = array_intersect_assoc($this->configuration['include'], $this->configuration['exclude']);
- if (\count($intersect) > 0) {
- throw new InvalidFixerConfigurationException($this->getName(), sprintf('Annotation cannot be used in both "include" and "exclude" list, got duplicates: %s.', Utils::naturalLanguageJoin(array_keys($intersect))));
- }
- }
- }
|