* 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\Import; use PhpCsFixer\AbstractFixer; use PhpCsFixer\Fixer\ConfigurableFixerInterface; use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface; 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 Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; /** * @author Sebastiaan Stok * @author Dariusz Rumiński * @author Darius Matulionis * @author Adriano Pilger */ final class OrderedImportsFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface { /** * @internal */ public const IMPORT_TYPE_CLASS = 'class'; /** * @internal */ public const IMPORT_TYPE_CONST = 'const'; /** * @internal */ public const IMPORT_TYPE_FUNCTION = 'function'; /** * @internal */ public const SORT_ALPHA = 'alpha'; /** * @internal */ public const SORT_LENGTH = 'length'; /** * @internal */ public const SORT_NONE = 'none'; /** * Array of supported sort types in configuration. * * @var string[] */ private const SUPPORTED_SORT_TYPES = [self::IMPORT_TYPE_CLASS, self::IMPORT_TYPE_CONST, self::IMPORT_TYPE_FUNCTION]; /** * Array of supported sort algorithms in configuration. * * @var string[] */ private const SUPPORTED_SORT_ALGORITHMS = [self::SORT_ALPHA, self::SORT_LENGTH, self::SORT_NONE]; /** * {@inheritdoc} */ public function getDefinition(): FixerDefinitionInterface { return new FixerDefinition( 'Ordering `use` statements.', [ new CodeSample( " self::SORT_LENGTH] ), new CodeSample( ' self::SORT_LENGTH, 'imports_order' => [ self::IMPORT_TYPE_CONST, self::IMPORT_TYPE_CLASS, self::IMPORT_TYPE_FUNCTION, ], ] ), new CodeSample( ' self::SORT_ALPHA, 'imports_order' => [ self::IMPORT_TYPE_CONST, self::IMPORT_TYPE_CLASS, self::IMPORT_TYPE_FUNCTION, ], ] ), new CodeSample( ' self::SORT_NONE, 'imports_order' => [ self::IMPORT_TYPE_CONST, self::IMPORT_TYPE_CLASS, self::IMPORT_TYPE_FUNCTION, ], ] ), ] ); } /** * {@inheritdoc} * * Must run after GlobalNamespaceImportFixer, NoLeadingImportSlashFixer. */ public function getPriority(): int { return -30; } /** * {@inheritdoc} */ public function isCandidate(Tokens $tokens): bool { return $tokens->isTokenKindFound(T_USE); } /** * {@inheritdoc} */ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void { $tokensAnalyzer = new TokensAnalyzer($tokens); $namespacesImports = $tokensAnalyzer->getImportUseIndexes(true); if (0 === \count($namespacesImports)) { return; } $usesOrder = []; foreach ($namespacesImports as $uses) { $usesOrder[] = $this->getNewOrder(array_reverse($uses), $tokens); } $usesOrder = array_replace(...$usesOrder); $usesOrder = array_reverse($usesOrder, true); $mapStartToEnd = []; foreach ($usesOrder as $use) { $mapStartToEnd[$use['startIndex']] = $use['endIndex']; } // Now insert the new tokens, starting from the end foreach ($usesOrder as $index => $use) { $declarationTokens = Tokens::fromCode( sprintf( 'clearRange(0, 2); // clear `clearAt(\count($declarationTokens) - 1); // clear `;` $declarationTokens->clearEmptyTokens(); $tokens->overrideRange($index, $mapStartToEnd[$index], $declarationTokens); if ($use['group']) { // a group import must start with `use` and cannot be part of comma separated import list $prev = $tokens->getPrevMeaningfulToken($index); if ($tokens[$prev]->equals(',')) { $tokens[$prev] = new Token(';'); $tokens->insertAt($prev + 1, new Token([T_USE, 'use'])); if (!$tokens[$prev + 2]->isWhitespace()) { $tokens->insertAt($prev + 2, new Token([T_WHITESPACE, ' '])); } } } } } /** * {@inheritdoc} */ protected function createConfigurationDefinition(): FixerConfigurationResolverInterface { $supportedSortTypes = self::SUPPORTED_SORT_TYPES; return new FixerConfigurationResolver([ (new FixerOptionBuilder('sort_algorithm', 'whether the statements should be sorted alphabetically or by length, or not sorted')) ->setAllowedValues(self::SUPPORTED_SORT_ALGORITHMS) ->setDefault(self::SORT_ALPHA) ->getOption(), (new FixerOptionBuilder('imports_order', 'Defines the order of import types.')) ->setAllowedTypes(['array', 'null']) ->setAllowedValues([static function (?array $value) use ($supportedSortTypes): bool { if (null !== $value) { $missing = array_diff($supportedSortTypes, $value); if (\count($missing) > 0) { throw new InvalidOptionsException(sprintf( 'Missing sort %s "%s".', 1 === \count($missing) ? 'type' : 'types', implode('", "', $missing) )); } $unknown = array_diff($value, $supportedSortTypes); if (\count($unknown) > 0) { throw new InvalidOptionsException(sprintf( 'Unknown sort %s "%s".', 1 === \count($unknown) ? 'type' : 'types', implode('", "', $unknown) )); } } return true; }]) ->setDefault(null) ->getOption(), ]); } /** * This method is used for sorting the uses in a namespace. * * @param array $first * @param array $second * * @internal */ private function sortAlphabetically(array $first, array $second): int { // Replace backslashes by spaces before sorting for correct sort order $firstNamespace = str_replace('\\', ' ', $this->prepareNamespace($first['namespace'])); $secondNamespace = str_replace('\\', ' ', $this->prepareNamespace($second['namespace'])); return strcasecmp($firstNamespace, $secondNamespace); } /** * This method is used for sorting the uses statements in a namespace by length. * * @param array $first * @param array $second * * @internal */ private function sortByLength(array $first, array $second): int { $firstNamespace = (self::IMPORT_TYPE_CLASS === $first['importType'] ? '' : $first['importType'].' ').$this->prepareNamespace($first['namespace']); $secondNamespace = (self::IMPORT_TYPE_CLASS === $second['importType'] ? '' : $second['importType'].' ').$this->prepareNamespace($second['namespace']); $firstNamespaceLength = \strlen($firstNamespace); $secondNamespaceLength = \strlen($secondNamespace); if ($firstNamespaceLength === $secondNamespaceLength) { $sortResult = strcasecmp($firstNamespace, $secondNamespace); } else { $sortResult = $firstNamespaceLength > $secondNamespaceLength ? 1 : -1; } return $sortResult; } private function prepareNamespace(string $namespace): string { return trim(Preg::replace('%/\*(.*)\*/%s', '', $namespace)); } /** * @param int[] $uses */ private function getNewOrder(array $uses, Tokens $tokens): array { $indexes = []; $originalIndexes = []; $lineEnding = $this->whitespacesConfig->getLineEnding(); for ($i = \count($uses) - 1; $i >= 0; --$i) { $index = $uses[$i]; $startIndex = $tokens->getTokenNotOfKindsSibling($index + 1, 1, [T_WHITESPACE]); $endIndex = $tokens->getNextTokenOfKind($startIndex, [';', [T_CLOSE_TAG]]); $previous = $tokens->getPrevMeaningfulToken($endIndex); $group = $tokens[$previous]->isGivenKind(CT::T_GROUP_IMPORT_BRACE_CLOSE); if ($tokens[$startIndex]->isGivenKind(CT::T_CONST_IMPORT)) { $type = self::IMPORT_TYPE_CONST; $index = $tokens->getNextNonWhitespace($startIndex); } elseif ($tokens[$startIndex]->isGivenKind(CT::T_FUNCTION_IMPORT)) { $type = self::IMPORT_TYPE_FUNCTION; $index = $tokens->getNextNonWhitespace($startIndex); } else { $type = self::IMPORT_TYPE_CLASS; $index = $startIndex; } $namespaceTokens = []; while ($index <= $endIndex) { $token = $tokens[$index]; if ($index === $endIndex || (!$group && $token->equals(','))) { if ($group && self::SORT_NONE !== $this->configuration['sort_algorithm']) { // if group import, sort the items within the group definition // figure out where the list of namespace parts within the group def. starts $namespaceTokensCount = \count($namespaceTokens) - 1; $namespace = ''; for ($k = 0; $k < $namespaceTokensCount; ++$k) { if ($namespaceTokens[$k]->isGivenKind(CT::T_GROUP_IMPORT_BRACE_OPEN)) { $namespace .= '{'; break; } $namespace .= $namespaceTokens[$k]->getContent(); } // fetch all parts, split up in an array of strings, move comments to the end $parts = []; $firstIndent = ''; $separator = ', '; $lastIndent = ''; $hasGroupTrailingComma = false; for ($k1 = $k + 1; $k1 < $namespaceTokensCount; ++$k1) { $comment = ''; $namespacePart = ''; for ($k2 = $k1;; ++$k2) { if ($namespaceTokens[$k2]->equalsAny([',', [CT::T_GROUP_IMPORT_BRACE_CLOSE]])) { break; } if ($namespaceTokens[$k2]->isComment()) { $comment .= $namespaceTokens[$k2]->getContent(); continue; } // if there is any line ending inside the group import, it should be indented properly if ( '' === $firstIndent && $namespaceTokens[$k2]->isWhitespace() && str_contains($namespaceTokens[$k2]->getContent(), $lineEnding) ) { $lastIndent = $lineEnding; $firstIndent = $lineEnding.$this->whitespacesConfig->getIndent(); $separator = ','.$firstIndent; } $namespacePart .= $namespaceTokens[$k2]->getContent(); } $namespacePart = trim($namespacePart); if ('' === $namespacePart) { $hasGroupTrailingComma = true; continue; } $comment = trim($comment); if ('' !== $comment) { $namespacePart .= ' '.$comment; } $parts[] = $namespacePart; $k1 = $k2; } $sortedParts = $parts; sort($parts); // check if the order needs to be updated, otherwise don't touch as we might change valid CS (to other valid CS). if ($sortedParts === $parts) { $namespace = Tokens::fromArray($namespaceTokens)->generateCode(); } else { $namespace .= $firstIndent.implode($separator, $parts).($hasGroupTrailingComma ? ',' : '').$lastIndent.'}'; } } else { $namespace = Tokens::fromArray($namespaceTokens)->generateCode(); } $indexes[$startIndex] = [ 'namespace' => $namespace, 'startIndex' => $startIndex, 'endIndex' => $index - 1, 'importType' => $type, 'group' => $group, ]; $originalIndexes[] = $startIndex; if ($index === $endIndex) { break; } $namespaceTokens = []; $nextPartIndex = $tokens->getTokenNotOfKindSibling($index, 1, [[','], [T_WHITESPACE]]); $startIndex = $nextPartIndex; $index = $nextPartIndex; continue; } $namespaceTokens[] = $token; ++$index; } } // Is sort types provided, sorting by groups and each group by algorithm if (null !== $this->configuration['imports_order']) { // Grouping indexes by import type. $groupedByTypes = []; foreach ($indexes as $startIndex => $item) { $groupedByTypes[$item['importType']][$startIndex] = $item; } // Sorting each group by algorithm. foreach ($groupedByTypes as $type => $groupIndexes) { $groupedByTypes[$type] = $this->sortByAlgorithm($groupIndexes); } // Ordering groups $sortedGroups = []; foreach ($this->configuration['imports_order'] as $type) { if (isset($groupedByTypes[$type]) && !empty($groupedByTypes[$type])) { foreach ($groupedByTypes[$type] as $startIndex => $item) { $sortedGroups[$startIndex] = $item; } } } $indexes = $sortedGroups; } else { // Sorting only by algorithm $indexes = $this->sortByAlgorithm($indexes); } $index = -1; $usesOrder = []; // Loop through the index but use original index order foreach ($indexes as $v) { $usesOrder[$originalIndexes[++$index]] = $v; } return $usesOrder; } /** * @param array[] $indexes */ private function sortByAlgorithm(array $indexes): array { if (self::SORT_ALPHA === $this->configuration['sort_algorithm']) { uasort($indexes, [$this, 'sortAlphabetically']); } elseif (self::SORT_LENGTH === $this->configuration['sort_algorithm']) { uasort($indexes, [$this, 'sortByLength']); } return $indexes; } }