* Dariusz Rumiński * * 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 */ 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(" ['@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(['array']) ->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(['array']) ->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(['array']) ->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(['array']) ->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 $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)))); } } }