From bb0bac32d9612379bd19c914874a33f70768c120 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 16 Jan 2024 01:23:05 +0100 Subject: [PATCH] Fixes #4. Implemented ErickSkrauch/ordered_overrides fixer --- CHANGELOG.md | 2 + README.md | 20 ++ composer.json | 2 +- composer.lock | 17 +- src/Analyzer/ClassNameAnalyzer.php | 72 +++++ src/ClassNotation/OrderedOverridesFixer.php | 258 ++++++++++++++++++ .../OrderedOverridesFixerTest.php | 222 +++++++++++++++ tests/ClassNotation/_data/AbstractA.php | 11 + tests/ClassNotation/_data/EmptyInterface.php | 8 + tests/ClassNotation/_data/InterfaceA.php | 12 + tests/ClassNotation/_data/InterfaceAandB.php | 10 + tests/ClassNotation/_data/InterfaceB.php | 12 + 12 files changed, 638 insertions(+), 8 deletions(-) create mode 100644 src/Analyzer/ClassNameAnalyzer.php create mode 100644 src/ClassNotation/OrderedOverridesFixer.php create mode 100644 tests/ClassNotation/OrderedOverridesFixerTest.php create mode 100644 tests/ClassNotation/_data/AbstractA.php create mode 100644 tests/ClassNotation/_data/EmptyInterface.php create mode 100644 tests/ClassNotation/_data/InterfaceA.php create mode 100644 tests/ClassNotation/_data/InterfaceAandB.php create mode 100644 tests/ClassNotation/_data/InterfaceB.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 360735a..6609bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Enh #4: Introduce `ErickSkrauch\ordered_overrides` fixer. ## [1.2.4] - 2024-01-15 ### Fixed diff --git a/README.md b/README.md index 924cc0d..bafb1a1 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,26 @@ Ensures that multiline if statement body curly brace placed on the right line. * `keep_on_own_line` - should this place closing bracket on its own line? If it's set to `false`, than curly bracket will be placed right after the last condition statement. **Default**: `true`. +### `ErickSkrauch/ordered_overrides` + +Overridden and implemented methods must be sorted in the same order as they are defined in parent classes. + +```diff +--- Original ++++ New +@@ @@ + namespacesAnalyzer = new NamespacesAnalyzer(); + $this->namespacesUsesAnalyzer = new NamespaceUsesAnalyzer(); + } + + /** + * @see https://www.php.net/manual/en/language.namespaces.rules.php + * + * @phpstan-return class-string + */ + public function getFqn(Tokens $tokens, int $classNameIndex): string { + $firstPart = $tokens[$classNameIndex]; + if (!$firstPart->isGivenKind([T_STRING, T_NS_SEPARATOR])) { + throw new LogicException(sprintf('No T_STRING or T_NS_SEPARATOR at given index %d, got "%s".', $classNameIndex, $firstPart->getName())); + } + + $relativeClassName = $this->readClassName($tokens, $classNameIndex); + if (str_starts_with($relativeClassName, '\\')) { + return $relativeClassName; // @phpstan-ignore return.type + } + + $namespace = $this->namespacesAnalyzer->getNamespaceAt($tokens, $classNameIndex); + $uses = $this->namespacesUsesAnalyzer->getDeclarationsInNamespace($tokens, $namespace); + $parts = explode('\\', $relativeClassName, 2); + /** @var \PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis $use */ + foreach ($uses as $use) { + if ($use->getShortName() !== $parts[0]) { + continue; + } + + // @phpstan-ignore return.type + return '\\' . $use->getFullName() . (isset($parts[1]) ? ('\\' . $parts[1]) : ''); + } + + // @phpstan-ignore return.type + return ($namespace->getFullName() !== '' ? '\\' : '') . $namespace->getFullName() . '\\' . $relativeClassName; + } + + private function readClassName(Tokens $tokens, int $classNameStart): string { + $className = ''; + $index = $classNameStart; + do { + $token = $tokens[$index]; + if ($token->isWhitespace()) { + continue; + } + + $className .= $token->getContent(); + } while ($tokens[++$index]->isGivenKind([T_STRING, T_NS_SEPARATOR, T_WHITESPACE])); + + return $className; + } + +} diff --git a/src/ClassNotation/OrderedOverridesFixer.php b/src/ClassNotation/OrderedOverridesFixer.php new file mode 100644 index 0000000..34fa3ea --- /dev/null +++ b/src/ClassNotation/OrderedOverridesFixer.php @@ -0,0 +1,258 @@ +classNameAnalyzer = new ClassNameAnalyzer(); + } + + public function isCandidate(Tokens $tokens): bool { + return $tokens->isAnyTokenKindsFound([T_CLASS, T_INTERFACE]); + } + + public function getDefinition(): FixerDefinitionInterface { + return new FixerDefinition( + 'Overridden and implemented methods must be sorted in the same order as they are defined in parent classes.', + [ + new CodeSample(' 65 + */ + public function getPriority(): int { + return 75; + } + + /** + * @throws \ReflectionException + */ + protected function applyFix(SplFileInfo $file, Tokens $tokens): void { + for ($i = 1, $count = $tokens->count(); $i < $count; ++$i) { + $classToken = $tokens[$i]; + if (!$classToken->isGivenKind([T_CLASS, T_INTERFACE])) { + continue; + } + + $methodsOrder = []; + + $extends = $this->getClassExtensions($tokens, $i, T_EXTENDS); + $interfaces = $this->getClassExtensions($tokens, $i, T_IMPLEMENTS); + $extensions = array_merge($extends, $interfaces); + if (count($extensions) === 0) { + continue; + } + + foreach ($extensions as $className) { + $classReflection = new ReflectionClass($className); + $parents = $this->getClassParents($classReflection, new SplStack()); + foreach ($parents as $parent) { + foreach ($parent->getMethods() as $method) { + if (!in_array($method->getShortName(), $methodsOrder, true)) { + $methodsOrder[] = $method->getShortName(); + } + } + } + } + + if (count($methodsOrder) === 0) { + continue; + } + + /** @var array $methodsPriority */ + $methodsPriority = array_flip(array_reverse($methodsOrder)); + + $classBodyStart = $tokens->getNextTokenOfKind($i, ['{']); + $classBodyEnd = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $classBodyStart); + + /** @var list $unsortedMethods */ + $unsortedMethods = []; + // TODO: actually there still might be properties and traits in between methods declarations + for ($j = $classBodyStart; $j < $classBodyEnd; $j++) { + $functionToken = $tokens[$j]; + if (!$functionToken->isGivenKind(T_FUNCTION)) { + continue; + } + + $methodNameToken = $tokens[$tokens->getNextMeaningfulToken($j)]; + // Ensure it's not an anonymous function + if ($methodNameToken->equals('(')) { + continue; + } + + $methodName = $methodNameToken->getContent(); + + // Take the closest whitespace to the beginning of the method + $methodStart = $tokens->getPrevTokenOfKind($j, ['}', ';', '{']) + 1; + $methodEnd = $this->findElementEnd($tokens, $j); + + $unsortedMethods[] = [ + 'name' => $methodName, + 'start' => $methodStart, + 'end' => $methodEnd, + ]; + } + + $sortedMethods = $this->sortMethods($unsortedMethods, $methodsPriority); + if ($sortedMethods === $unsortedMethods) { + continue; + } + + $startIndex = $unsortedMethods[array_key_first($unsortedMethods)]['start']; + $endIndex = $unsortedMethods[array_key_last($unsortedMethods)]['end']; + $replaceTokens = []; + foreach ($sortedMethods as $method) { + for ($k = $method['start']; $k <= $method['end']; ++$k) { + $replaceTokens[] = clone $tokens[$k]; + } + } + + $tokens->overrideRange($startIndex, $endIndex, $replaceTokens); + + $i = $endIndex; + } + } + + /** + * @return array + */ + private function getClassExtensions(Tokens $tokens, int $classTokenIndex, int $extensionType): array { + $extensionTokenIndex = $tokens->getNextTokenOfKind($classTokenIndex, [[$extensionType], '{']); + if (!$tokens[$extensionTokenIndex]->isGivenKind($extensionType)) { + return []; + } + + $classNames = []; + $classStartIndex = $tokens->getNextMeaningfulToken($extensionTokenIndex); + do { + $nextDelimiterIndex = $tokens->getNextTokenOfKind($classStartIndex, [',', '{']); + $classNames[] = $this->classNameAnalyzer->getFqn($tokens, $classStartIndex); + $classStartIndex = $tokens->getNextMeaningfulToken($nextDelimiterIndex); + } while ($tokens[$nextDelimiterIndex]->getContent() === ','); + + return $classNames; + } + + /** + * @param ReflectionClass $class + * @param SplStack> $stack + * + * @return SplStack> + */ + private function getClassParents(ReflectionClass $class, SplStack $stack): SplStack { + $stack->push($class); + $parent = $class->getParentClass(); + if ($parent !== false) { + $stack = $this->getClassParents($parent, $stack); + } + + $interfaces = $class->getInterfaces(); + if (count($interfaces) > 0) { + foreach (array_reverse($interfaces) as $interface) { + $stack = $this->getClassParents($interface, $stack); + } + } + + return $stack; + } + + /** + * Taken from the OrderedClassElementsFixer + */ + private function findElementEnd(Tokens $tokens, int $index): int { + $blockStart = $tokens->getNextTokenOfKind($index, ['{', ';']); + if ($tokens[$blockStart]->equals('{')) { + $blockEnd = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $blockStart); + } else { + $blockEnd = $blockStart; + } + + for (++$blockEnd; $tokens[$blockEnd]->isWhitespace(" \t") || $tokens[$blockEnd]->isComment(); ++$blockEnd); + + --$blockEnd; + + return $tokens[$blockEnd]->isWhitespace() ? $blockEnd - 1 : $blockEnd; + } + + /** + * @phpstan-param list $methods + * @phpstan-param array $methodsPriority + * + * @phpstan-return list + */ + private function sortMethods(array $methods, array $methodsPriority): array { + $count = count($methods); + $targetPriority = $methodsPriority[array_key_last($methodsPriority)]; + for ($i = 0; $i < $count; $i++) { + $a = $methods[$i]; + if (!isset($methodsPriority[$a['name']])) { + continue; + } + + $priorityA = $methodsPriority[$a['name']]; + if ($priorityA === $targetPriority) { + $targetPriority--; + continue; + } + + do { + for ($j = $i + 1; $j < $count; $j++) { + $b = $methods[$j]; + if (!isset($methodsPriority[$b['name']])) { + continue; + } + + $priorityB = $methodsPriority[$b['name']]; + if ($priorityB === $targetPriority) { + $methods[$i] = $b; + $methods[$j] = $a; + $targetPriority--; + + continue 3; + } + } + } while ($targetPriority > $priorityA && $targetPriority-- >= 0); // @phpstan-ignore greaterOrEqual.alwaysTrue + } + + // @phpstan-ignore return.type + return $methods; + } + +} diff --git a/tests/ClassNotation/OrderedOverridesFixerTest.php b/tests/ClassNotation/OrderedOverridesFixerTest.php new file mode 100644 index 0000000..8739572 --- /dev/null +++ b/tests/ClassNotation/OrderedOverridesFixerTest.php @@ -0,0 +1,222 @@ +doTest($expected, $input); + } + + /** + * @return iterable + */ + public function provideTestCases(): iterable { + yield 'no extends, no implements' => [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + '