From 0fa206efa8fa118306dacca9bca9e89c8461a0a0 Mon Sep 17 00:00:00 2001 From: k0d3r1s Date: Tue, 20 Aug 2024 14:09:44 +0300 Subject: [PATCH] feat: add custom php cs fixers --- Fixer/DeclareAfterOpeningTagFixer.php | 107 +++ Fixer/DoctrineMigrationsFixer.php | 118 +++ Fixer/IssetToArrayKeyExistsFixer.php | 103 ++ .../LineBreakBetweenMethodArgumentsFixer.php | 212 +++++ Fixer/LineBreakBetweenStatementsFixer.php | 189 ++++ Fixer/NoUselessDirnameCallFixer.php | 166 ++++ Fixer/NoUselessStrlenFixer.php | 194 ++++ Fixer/PromotedConstructorPropertyFixer.php | 415 ++++++++ Fixers.php | 81 ++ LICENSE | 28 + PhpCsFixer/AbstractFixer.php | 321 +++++++ PhpCsFixer/Analyzer/Analyzer.php | 883 ++++++++++++++++++ PhpCsFixer/Analyzer/Element/Argument.php | 40 + PhpCsFixer/Analyzer/Element/ArrayElement.php | 46 + PhpCsFixer/Analyzer/Element/CaseElement.php | 28 + PhpCsFixer/Analyzer/Element/Constructor.php | 195 ++++ PhpCsFixer/Analyzer/Element/SwitchElement.php | 40 + composer.json | 32 + 18 files changed, 3198 insertions(+) create mode 100644 Fixer/DeclareAfterOpeningTagFixer.php create mode 100644 Fixer/DoctrineMigrationsFixer.php create mode 100644 Fixer/IssetToArrayKeyExistsFixer.php create mode 100644 Fixer/LineBreakBetweenMethodArgumentsFixer.php create mode 100644 Fixer/LineBreakBetweenStatementsFixer.php create mode 100644 Fixer/NoUselessDirnameCallFixer.php create mode 100644 Fixer/NoUselessStrlenFixer.php create mode 100644 Fixer/PromotedConstructorPropertyFixer.php create mode 100644 Fixers.php create mode 100644 LICENSE create mode 100644 PhpCsFixer/AbstractFixer.php create mode 100644 PhpCsFixer/Analyzer/Analyzer.php create mode 100644 PhpCsFixer/Analyzer/Element/Argument.php create mode 100644 PhpCsFixer/Analyzer/Element/ArrayElement.php create mode 100644 PhpCsFixer/Analyzer/Element/CaseElement.php create mode 100644 PhpCsFixer/Analyzer/Element/Constructor.php create mode 100644 PhpCsFixer/Analyzer/Element/SwitchElement.php create mode 100644 composer.json diff --git a/Fixer/DeclareAfterOpeningTagFixer.php b/Fixer/DeclareAfterOpeningTagFixer.php new file mode 100644 index 0000000..539bc84 --- /dev/null +++ b/Fixer/DeclareAfterOpeningTagFixer.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\Fixer; + +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use SplFileInfo; +use Vairogs\Component\Functions\Preg\_Replace; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; + +use function assert; +use function is_int; +use function stripos; +use function substr; + +use const T_DECLARE; +use const T_OPEN_TAG; +use const T_WHITESPACE; + +/** + * @internal + */ +final class DeclareAfterOpeningTagFixer extends AbstractFixer +{ + public function applyFix( + SplFileInfo $file, + Tokens $tokens, + ): void { + if (!$tokens[0]->isGivenKind(T_OPEN_TAG)) { + return; + } + + $openingTagTokenContent = $tokens[0]->getContent(); + + $declareIndex = $tokens->getNextTokenOfKind(0, [[T_DECLARE]]); + assert(is_int($declareIndex)); + + $openParenthesisIndex = $tokens->getNextMeaningfulToken($declareIndex); + assert(is_int($openParenthesisIndex)); + + $closeParenthesisIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesisIndex); + + if (false === stripos($tokens->generatePartialCode($openParenthesisIndex, $closeParenthesisIndex), 'strict_types')) { + return; + } + + $tokens[0] = new Token([T_OPEN_TAG, substr($openingTagTokenContent, 0, 5) . ' ']); + + if ($declareIndex <= 2) { + $tokens->clearRange(1, $declareIndex - 1); + + return; + } + + $semicolonIndex = $tokens->getNextMeaningfulToken($closeParenthesisIndex); + assert(is_int($semicolonIndex)); + + $tokensToInsert = []; + for ($index = $declareIndex; $index <= $semicolonIndex; $index++) { + $tokensToInsert[] = $tokens[$index]; + } + + if ($tokens[1]->isGivenKind(T_WHITESPACE)) { + $tokens[1] = new Token([T_WHITESPACE, substr($openingTagTokenContent, 5) . $tokens[1]->getContent()]); + } else { + $tokensToInsert[] = new Token([T_WHITESPACE, substr($openingTagTokenContent, 5)]); + } + + if ($tokens[$semicolonIndex + 1]->isGivenKind(T_WHITESPACE)) { + $content = (new class { + use _Replace; + })::replace('/^(\\R?)(?=\\R)/', '', $tokens[$semicolonIndex + 1]->getContent()); + + $tokens->ensureWhitespaceAtIndex($semicolonIndex + 1, 0, $content); + } + + $tokens->clearRange($declareIndex + 1, $semicolonIndex); + self::removeWithLinesIfPossible($tokens, $declareIndex); + + $tokens->insertAt(1, $tokensToInsert); + } + + public function getDocumentation(): string + { + return 'Declare statement for strict types must be placed on the same line, after the opening tag.'; + } + + public function getSampleCode(): string + { + return "isTokenKindFound(T_DECLARE); + } +} diff --git a/Fixer/DoctrineMigrationsFixer.php b/Fixer/DoctrineMigrationsFixer.php new file mode 100644 index 0000000..1328910 --- /dev/null +++ b/Fixer/DoctrineMigrationsFixer.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\Fixer; + +use Doctrine\Migrations\AbstractMigration; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use SplFileInfo; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; + +use function class_exists; +use function explode; +use function implode; +use function in_array; +use function trim; + +use const T_COMMENT; + +/** + * @internal + */ +final class DoctrineMigrationsFixer extends AbstractFixer +{ + public function getDocumentation(): string + { + return 'Unnecessary comments MUST BE removed from Doctrine migrations'; + } + + public function getSampleCode(): string + { + return <<<'SPEC' + extendsClass($tokens, AbstractMigration::class); + } + + protected function applyFix( + SplFileInfo $file, + Tokens $tokens, + ): void { + $this->removeUselessComments($tokens); + } + + private function removeUselessComments( + Tokens $tokens, + ): void { + $blacklist = [ + 'Auto-generated Migration: Please modify to your needs!', + 'this up() migration is auto-generated, please modify it to your needs', + 'this down() migration is auto-generated, please modify it to your needs', + ]; + + foreach ($this->getComments($tokens) as $position => $comment) { + $lines = explode("\n", $comment->getContent()); + $changed = false; + + foreach ($lines as $index => $line) { + if (in_array(trim($line, '/* '), $blacklist, true)) { + unset($lines[$index]); + $changed = true; + } + } + + if (false === $changed) { + continue; + } + + if (empty(trim(implode("\n", $lines), " /*\n"))) { + $tokens->clearAt($position); + $tokens->removeTrailingWhitespace($position); + + continue; + } + + $tokens[$position] = new Token([T_COMMENT, implode("\n", $lines)]); + } + } +} diff --git a/Fixer/IssetToArrayKeyExistsFixer.php b/Fixer/IssetToArrayKeyExistsFixer.php new file mode 100644 index 0000000..0d9cfee --- /dev/null +++ b/Fixer/IssetToArrayKeyExistsFixer.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\Fixer; + +use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use SplFileInfo; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; + +use function assert; +use function count; +use function is_int; + +use const T_ISSET; +use const T_STRING; +use const T_WHITESPACE; + +/** + * @internal + */ +final class IssetToArrayKeyExistsFixer extends AbstractFixer +{ + public function applyFix( + SplFileInfo $file, + Tokens $tokens, + ): void { + for ($index = $tokens->count() - 1; $index > 0; $index--) { + if (!$tokens[$index]->isGivenKind(T_ISSET)) { + continue; + } + + if (1 !== count((new FunctionsAnalyzer())->getFunctionArguments($tokens, $index))) { + continue; + } + + $openParenthesis = $tokens->getNextMeaningfulToken($index); + assert(is_int($openParenthesis)); + + $closeParenthesis = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesis); + + $closeBrackets = $tokens->getPrevMeaningfulToken($closeParenthesis); + assert(is_int($closeBrackets)); + if (!$tokens[$closeBrackets]->equals(']')) { + continue; + } + + $openBrackets = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $closeBrackets); + + $keyStartIndex = $tokens->getNextMeaningfulToken($openBrackets); + assert(is_int($keyStartIndex)); + $keyEndIndex = $tokens->getPrevMeaningfulToken($closeBrackets); + + $keyTokens = []; + for ($i = $keyStartIndex; $i <= $keyEndIndex; $i++) { + if ($tokens[$i]->equals('')) { + continue; + } + $keyTokens[] = $tokens[$i]; + } + $keyTokens[] = new Token(','); + $keyTokens[] = new Token([T_WHITESPACE, ' ']); + + $tokens->clearRange($openBrackets, $closeBrackets); + $tokens->insertAt($openParenthesis + 1, $keyTokens); + $tokens[$index] = new Token([T_STRING, 'array_key_exists']); + } + } + + public function getDocumentation(): string + { + return 'Function `array_key_exists` must be used instead of `isset` when possible.'; + } + + public function getSampleCode(): string + { + return 'isTokenKindFound(T_ISSET); + } + + public function isRisky(): bool + { + return true; + } +} diff --git a/Fixer/LineBreakBetweenMethodArgumentsFixer.php b/Fixer/LineBreakBetweenMethodArgumentsFixer.php new file mode 100644 index 0000000..4cb7cbc --- /dev/null +++ b/Fixer/LineBreakBetweenMethodArgumentsFixer.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\Fixer; + +use Exception; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use SplFileInfo; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; + +use function array_reverse; +use function assert; +use function is_int; +use function sort; + +use const T_FUNCTION; +use const T_STRING; +use const T_WHITESPACE; + +/** + * @internal + */ +final class LineBreakBetweenMethodArgumentsFixer extends AbstractFixer +{ + public const int T_TYPEHINT_SEMI_COLON = 10025; + + public function getDocumentation(): string + { + return 'Move function arguments to separate lines (one argument per line)'; + } + + public function getSampleCode(): string + { + return <<<'SPEC' + $token) { + if (T_FUNCTION === $token->getId()) { + $functions[$index] = $token; + } + } + + foreach (array_reverse($functions, true) as $index => $token) { + $nextIndex = $tokens->getNextMeaningfulToken($index); + $next = $tokens[$nextIndex]; + + if (null === $nextIndex) { + continue; + } + + if (T_STRING !== $next->getId()) { + continue; + } + + $openBraceIndex = $tokens->getNextMeaningfulToken($nextIndex); + $openBrace = $tokens[$openBraceIndex]; + + if ('(' !== $openBrace->getContent()) { + continue; + } + + if (0 === $this->analyze($tokens)->getNumberOfArguments($index)) { + $this->mergeArgs($tokens, $index); + + continue; + } + + if ($this->analyze($tokens)->getSizeOfTheLine($index) > 1) { + $this->splitArgs($tokens, $index); + + continue; + } + + $clonedTokens = clone $tokens; + $this->mergeArgs($clonedTokens, $index); + + if ($this->analyze($clonedTokens)->getSizeOfTheLine($index) > 1) { + $this->splitArgs($tokens, $index); + } else { + $this->mergeArgs($tokens, $index); + } + } + } + + /** + * @throws Exception + */ + private function mergeArgs( + Tokens $tokens, + $index, + ): void { + $openBraceIndex = $tokens->getNextTokenOfKind($index, ['(']); + $closeBraceIndex = $this->analyze($tokens)->getClosingParenthesis($openBraceIndex); + + foreach ($tokens->findGivenKind(T_WHITESPACE, $openBraceIndex, $closeBraceIndex) as $spaceIndex => $spaceToken) { + $tokens[$spaceIndex] = new Token([T_WHITESPACE, ' ']); + } + + $tokens->removeTrailingWhitespace($openBraceIndex); + $tokens->removeLeadingWhitespace($closeBraceIndex); + + $end = $tokens->getNextTokenOfKind($closeBraceIndex, [';', '{']); + + if ('{' === $tokens[$end]->getContent()) { + $tokens->removeLeadingWhitespace($end); + $tokens->ensureWhitespaceAtIndex($end, -1, "\n" . $this->analyze($tokens)->getLineIndentation($index)); + } + } + + /** + * @throws Exception + */ + private function splitArgs( + Tokens $tokens, + $index, + ): void { + $this->mergeArgs($tokens, $index); + + $openBraceIndex = $tokens->getNextTokenOfKind($index, ['(']); + $closeBraceIndex = $this->analyze($tokens)->getClosingParenthesis($openBraceIndex); + + if (0 === $closeBraceIndex) { + return; + } + + if ('{' === $tokens[$tokens->getNextMeaningfulToken($closeBraceIndex)]->getContent()) { + $tokens->removeTrailingWhitespace($closeBraceIndex); + $tokens->ensureWhitespaceAtIndex($closeBraceIndex, 1, ' '); + } + + if ($tokens[$tokens->getNextMeaningfulToken($closeBraceIndex)]->isGivenKind(self::T_TYPEHINT_SEMI_COLON)) { + $end = $tokens->getNextTokenOfKind($closeBraceIndex, [';', '{']); + + $tokens->removeLeadingWhitespace($end); + + if (';' !== $tokens[$end]->getContent()) { + $tokens->ensureWhitespaceAtIndex($end, 0, ' '); + } + } + + $linebreaks = [$openBraceIndex, $closeBraceIndex - 1]; + assert(is_int($closeBraceIndex)); + + for ($i = $openBraceIndex + 1; $i < $closeBraceIndex; $i++) { + if ('(' === $tokens[$i]->getContent()) { + $i = $this->analyze($tokens)->getClosingParenthesis($i); + } + + if ('[' === $tokens[$i]->getContent()) { + $i = $this->analyze($tokens)->getClosingBracket($i); + } + + if (',' === $tokens[$i]->getContent()) { + $linebreaks[] = $i; + } + } + + sort($linebreaks); + + foreach (array_reverse($linebreaks) as $iteration => $linebreak) { + $tokens->removeTrailingWhitespace($linebreak); + + $whitespace = match ($iteration) { + 0 => "\n" . $this->analyze($tokens)->getLineIndentation($index), + default => "\n" . $this->analyze($tokens)->getLineIndentation($index) . ' ', + }; + + $tokens->ensureWhitespaceAtIndex($linebreak, 1, $whitespace); + } + } +} diff --git a/Fixer/LineBreakBetweenStatementsFixer.php b/Fixer/LineBreakBetweenStatementsFixer.php new file mode 100644 index 0000000..3768d55 --- /dev/null +++ b/Fixer/LineBreakBetweenStatementsFixer.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\Fixer; + +use Exception; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use SplFileInfo; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; + +use function array_key_exists; +use function array_keys; +use function array_pad; +use function array_slice; +use function count; +use function explode; +use function implode; + +use const T_DO; +use const T_FOR; +use const T_FOREACH; +use const T_IF; +use const T_SWITCH; +use const T_WHILE; +use const T_WHITESPACE; + +/** + * @internal + */ +final class LineBreakBetweenStatementsFixer extends AbstractFixer +{ + private const array HANDLERS = [ + T_DO => 'do', + T_FOR => 'common', + T_FOREACH => 'common', + T_IF => 'common', + T_SWITCH => 'common', + T_WHILE => 'common', + ]; + + public function getDocumentation(): string + { + return 'Each statement (in, for, foreach, ...) MUST BE separated by an empty line'; + } + + public function getSampleCode(): string + { + return <<<'PHP' + findGivenKind(array_keys(self::HANDLERS)) as $kind => $matchedTokens) { + match (self::HANDLERS[$kind]) { + 'do' => $this->handleDo($matchedTokens, $tokens), + default => $this->handleCommon($matchedTokens, $tokens), + }; + } + } + + private function ensureNumberOfBreaks( + string $whitespace, + ): string { + $parts = explode("\n", $whitespace); + $currentCount = count($parts); + $desiredCount = 3; + + if ($currentCount > $desiredCount) { + $parts = array_slice($parts, $currentCount - $desiredCount); + } + + if ($currentCount < $desiredCount) { + $parts = array_pad($parts, -$desiredCount, ''); + } + + return implode("\n", $parts); + } + + private function fixSpaces( + int $index, + Tokens $tokens, + ): void { + $space = $index + 1; + + if (!$tokens[$space]->isWhitespace()) { + return; + } + + $nextMeaningful = $tokens->getNextMeaningfulToken($index); + + if (null === $nextMeaningful) { + return; + } + + if (!array_key_exists($tokens[$nextMeaningful]->getId(), self::HANDLERS)) { + return; + } + + $tokens[$space] = new Token([T_WHITESPACE, $this->ensureNumberOfBreaks($tokens[$space]->getContent())]); + } + + /** + * @throws Exception + */ + private function handleCommon( + array $matchedTokens, + Tokens $tokens, + ): void { + foreach ($matchedTokens as $index => $token) { + $curlyBracket = $tokens->findSequence([ + '{', + ], $index); + + if (empty($curlyBracket)) { + continue; + } + + $openCurlyBracket = array_key_first($curlyBracket); + + if (false === $openCurlyBracket) { + continue; + } + + $closeCurlyBracket = $this->analyze($tokens)->getClosingCurlyBracket($openCurlyBracket); + + if (null === $closeCurlyBracket) { + continue; + } + + $this->fixSpaces( + $closeCurlyBracket, + $tokens, + ); + } + } + + /** + * @throws Exception + */ + private function handleDo( + array $matchedTokens, + Tokens $tokens, + ): void { + foreach ($matchedTokens as $index => $token) { + $this->fixSpaces( + $this->analyze($tokens)->getNextSemiColon($index), + $tokens, + ); + } + } +} diff --git a/Fixer/NoUselessDirnameCallFixer.php b/Fixer/NoUselessDirnameCallFixer.php new file mode 100644 index 0000000..dbe602b --- /dev/null +++ b/Fixer/NoUselessDirnameCallFixer.php @@ -0,0 +1,166 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\Fixer; + +use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use SplFileInfo; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; + +use function assert; +use function is_int; +use function str_repeat; +use function substr; + +use const T_CONSTANT_ENCAPSED_STRING; +use const T_DIR; +use const T_LNUMBER; +use const T_NS_SEPARATOR; +use const T_STRING; + +/** + * @internal + */ +final class NoUselessDirnameCallFixer extends AbstractFixer +{ + public function applyFix( + SplFileInfo $file, + Tokens $tokens, + ): void { + for ($index = $tokens->count() - 1; $index > 0; $index--) { + if (!$tokens[$index]->isGivenKind(T_DIR)) { + continue; + } + + $prevInserts = $this->getPrevTokensUpdates($tokens, $index); + if (null === $prevInserts) { + continue; + } + + $nextInserts = $this->getNextTokensUpdates($tokens, $index); + if (null === $nextInserts) { + continue; + } + + foreach ($prevInserts + $nextInserts as $i => $content) { + if ('' === $content) { + $tokens->clearTokenAndMergeSurroundingWhitespace($i); + } else { + $tokens[$i] = new Token([T_CONSTANT_ENCAPSED_STRING, $content]); + } + } + } + } + + public function getDocumentation(): string + { + return 'There must be no useless `dirname` calls.'; + } + + public function getSampleCode(): string + { + return 'isTokenKindFound(T_DIR); + } + + private function getNextTokensUpdates( + Tokens $tokens, + int $index, + ): ?array { + $depthLevel = 1; + $updates = []; + + $commaOrClosingParenthesisIndex = $tokens->getNextMeaningfulToken($index); + assert(is_int($commaOrClosingParenthesisIndex)); + if ($tokens[$commaOrClosingParenthesisIndex]->equals(',')) { + $updates[$commaOrClosingParenthesisIndex] = ''; + $afterCommaIndex = $tokens->getNextMeaningfulToken($commaOrClosingParenthesisIndex); + assert(is_int($afterCommaIndex)); + if ($tokens[$afterCommaIndex]->isGivenKind(T_LNUMBER)) { + $depthLevel = (int) $tokens[$afterCommaIndex]->getContent(); + $updates[$afterCommaIndex] = ''; + $commaOrClosingParenthesisIndex = $tokens->getNextMeaningfulToken($afterCommaIndex); + assert(is_int($commaOrClosingParenthesisIndex)); + } + } + + if ($tokens[$commaOrClosingParenthesisIndex]->equals(',')) { + $updates[$commaOrClosingParenthesisIndex] = ''; + $commaOrClosingParenthesisIndex = $tokens->getNextMeaningfulToken($commaOrClosingParenthesisIndex); + assert(is_int($commaOrClosingParenthesisIndex)); + } + $closingParenthesisIndex = $commaOrClosingParenthesisIndex; + + if (!$tokens[$closingParenthesisIndex]->equals(')')) { + return null; + } + $updates[$closingParenthesisIndex] = ''; + + $concatenationIndex = $tokens->getNextMeaningfulToken($closingParenthesisIndex); + assert(is_int($concatenationIndex)); + if (!$tokens[$concatenationIndex]->equals('.')) { + return null; + } + + $stringIndex = $tokens->getNextMeaningfulToken($concatenationIndex); + assert(is_int($stringIndex)); + if (!$tokens[$stringIndex]->isGivenKind(T_CONSTANT_ENCAPSED_STRING)) { + return null; + } + + $stringContent = $tokens[$stringIndex]->getContent(); + $updates[$stringIndex] = $stringContent[0] . str_repeat('/..', $depthLevel) . substr($stringContent, 1); + + return $updates; + } + + private function getPrevTokensUpdates( + Tokens $tokens, + int $index, + ): ?array { + $updates = []; + + $openParenthesisIndex = $tokens->getPrevMeaningfulToken($index); + assert(is_int($openParenthesisIndex)); + if (!$tokens[$openParenthesisIndex]->equals('(')) { + return null; + } + $updates[$openParenthesisIndex] = ''; + + $dirnameCallIndex = $tokens->getPrevMeaningfulToken($openParenthesisIndex); + assert(is_int($dirnameCallIndex)); + if (!$tokens[$dirnameCallIndex]->equals([T_STRING, 'dirname'], false)) { + return null; + } + + if (!(new FunctionsAnalyzer())->isGlobalFunctionCall($tokens, $dirnameCallIndex)) { + return null; + } + $updates[$dirnameCallIndex] = ''; + + $namespaceSeparatorIndex = $tokens->getPrevMeaningfulToken($dirnameCallIndex); + assert(is_int($namespaceSeparatorIndex)); + if ($tokens[$namespaceSeparatorIndex]->isGivenKind(T_NS_SEPARATOR)) { + $updates[$namespaceSeparatorIndex] = ''; + } + + return $updates; + } +} diff --git a/Fixer/NoUselessStrlenFixer.php b/Fixer/NoUselessStrlenFixer.php new file mode 100644 index 0000000..6de00c6 --- /dev/null +++ b/Fixer/NoUselessStrlenFixer.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\Fixer; + +use PhpCsFixer\Tokenizer\Analyzer\ArgumentsAnalyzer; +use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use SplFileInfo; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; + +use function assert; +use function is_int; + +use const T_CONSTANT_ENCAPSED_STRING; +use const T_IS_EQUAL; +use const T_IS_IDENTICAL; +use const T_IS_NOT_EQUAL; +use const T_IS_NOT_IDENTICAL; +use const T_LNUMBER; +use const T_NS_SEPARATOR; +use const T_STRING; + +/** + * @internal + */ +final class NoUselessStrlenFixer extends AbstractFixer +{ + public function applyFix( + SplFileInfo $file, + Tokens $tokens, + ): void { + $argumentsAnalyzer = new ArgumentsAnalyzer(); + $functionsAnalyzer = new FunctionsAnalyzer(); + + for ($index = $tokens->count() - 1; $index > 0; $index--) { + if (!$tokens[$index]->equalsAny([[T_STRING, 'strlen'], [T_STRING, 'mb_strlen']], false)) { + continue; + } + + if (!$functionsAnalyzer->isGlobalFunctionCall($tokens, $index)) { + continue; + } + + $openParenthesisIndex = $tokens->getNextTokenOfKind($index, ['(']); + assert(is_int($openParenthesisIndex)); + + $closeParenthesisIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesisIndex); + + if (1 !== $argumentsAnalyzer->countArguments($tokens, $openParenthesisIndex, $closeParenthesisIndex)) { + continue; + } + + $tokensToRemove = [ + $index => 1, + $openParenthesisIndex => 1, + $closeParenthesisIndex => -1, + ]; + + $prevIndex = $tokens->getPrevMeaningfulToken($index); + assert(is_int($prevIndex)); + + $startIndex = $index; + if ($tokens[$prevIndex]->isGivenKind(T_NS_SEPARATOR)) { + $startIndex = $prevIndex; + $tokensToRemove[$prevIndex] = 1; + } + + if (!$this->transformCondition($tokens, $startIndex, $closeParenthesisIndex)) { + continue; + } + + $this->removeTokenAndSiblingWhitespace($tokens, $tokensToRemove); + } + } + + public function getDocumentation(): string + { + return 'Functions `strlen` and `mb_strlen` must not be compared to 0.'; + } + + public function getSampleCode(): string + { + return ' 0; + '; + } + + public function isCandidate( + Tokens $tokens, + ): bool { + return $tokens->isTokenKindFound(T_LNUMBER) && $tokens->isAnyTokenKindsFound(['>', '<', T_IS_IDENTICAL, T_IS_NOT_IDENTICAL, T_IS_EQUAL, T_IS_NOT_EQUAL]); + } + + public function isRisky(): bool + { + return true; + } + + private function removeTokenAndSiblingWhitespace( + Tokens $tokens, + array $tokensToRemove, + ): void { + foreach ($tokensToRemove as $index => $direction) { + $tokens->clearAt($index); + + if ($tokens[$index + $direction]->isWhitespace()) { + $tokens->clearAt($index + $direction); + } + } + } + + private function transformCondition( + Tokens $tokens, + int $startIndex, + int $endIndex, + ): bool { + if ($this->transformConditionLeft($tokens, $startIndex)) { + return true; + } + + return $this->transformConditionRight($tokens, $endIndex); + } + + private function transformConditionLeft( + Tokens $tokens, + int $index, + ): bool { + $prevIndex = $tokens->getPrevMeaningfulToken($index); + assert(is_int($prevIndex)); + + $changeCondition = false; + if ($tokens[$prevIndex]->equals('<')) { + $changeCondition = true; + } elseif (!$tokens[$prevIndex]->isGivenKind([T_IS_IDENTICAL, T_IS_NOT_IDENTICAL, T_IS_EQUAL, T_IS_NOT_EQUAL])) { + return false; + } + + $prevPrevIndex = $tokens->getPrevMeaningfulToken($prevIndex); + assert(is_int($prevPrevIndex)); + + if (!$tokens[$prevPrevIndex]->equals([T_LNUMBER, '0'])) { + return false; + } + + if ($changeCondition) { + $tokens[$prevIndex] = new Token([T_IS_NOT_IDENTICAL, '!==']); + } + + $tokens[$prevPrevIndex] = new Token([T_CONSTANT_ENCAPSED_STRING, '\'\'']); + + return true; + } + + private function transformConditionRight( + Tokens $tokens, + int $index, + ): bool { + $nextIndex = $tokens->getNextMeaningfulToken($index); + assert(is_int($nextIndex)); + + $changeCondition = false; + if ($tokens[$nextIndex]->equals('>')) { + $changeCondition = true; + } elseif (!$tokens[$nextIndex]->isGivenKind([T_IS_IDENTICAL, T_IS_NOT_IDENTICAL, T_IS_EQUAL, T_IS_NOT_EQUAL])) { + return false; + } + + $nextNextIndex = $tokens->getNextMeaningfulToken($nextIndex); + assert(is_int($nextNextIndex)); + + if (!$tokens[$nextNextIndex]->equals([T_LNUMBER, '0'])) { + return false; + } + + if ($changeCondition) { + $tokens[$nextIndex] = new Token([T_IS_NOT_IDENTICAL, '!==']); + } + + $tokens[$nextNextIndex] = new Token([T_CONSTANT_ENCAPSED_STRING, '\'\'']); + + return true; + } +} diff --git a/Fixer/PromotedConstructorPropertyFixer.php b/Fixer/PromotedConstructorPropertyFixer.php new file mode 100644 index 0000000..11f48d7 --- /dev/null +++ b/Fixer/PromotedConstructorPropertyFixer.php @@ -0,0 +1,415 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\Fixer; + +use PhpCsFixer\DocBlock\DocBlock; +use PhpCsFixer\Tokenizer\CT; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use PhpCsFixer\Tokenizer\TokensAnalyzer; +use SplFileInfo; +use Vairogs\Component\Functions\Preg\_Match; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Analyzer; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element\Constructor; + +use function array_key_exists; +use function assert; +use function count; +use function in_array; +use function is_int; + +use const T_CLASS; +use const T_COMMENT; +use const T_DOC_COMMENT; +use const T_PRIVATE; +use const T_PROTECTED; +use const T_PUBLIC; +use const T_STRING; +use const T_VAR; +use const T_VARIABLE; +use const T_WHITESPACE; + +/** + * @internal + */ +final class PromotedConstructorPropertyFixer extends AbstractFixer +{ + private array $tokensToInsert; + + public function applyFix( + SplFileInfo $file, + Tokens $tokens, + ): void { + $constructorAnalyzer = new Analyzer($tokens); + $this->tokensToInsert = []; + + for ($index = $tokens->count() - 1; $index > 0; $index--) { + if (!$tokens[$index]->isGivenKind(T_CLASS)) { + continue; + } + + $constructorAnalysis = $constructorAnalyzer->findNonAbstractConstructor($index); + if (null === $constructorAnalysis) { + continue; + } + + $this->promoteProperties($tokens, $index, $constructorAnalysis); + } + + krsort($this->tokensToInsert); + + foreach ($this->tokensToInsert as $index => $tokensToInsert) { + $tokens->insertAt($index, $tokensToInsert); + } + } + + public function getDocumentation(): string + { + return 'Constructor properties must be promoted if possible.'; + } + + public function getSampleCode(): string + { + return 'bar = $bar; + } + } + '; + } + + public function isCandidate( + Tokens $tokens, + ): bool { + return $tokens->isAllTokenKindsFound([T_CLASS, T_VARIABLE]); + } + + private function getClassProperties( + Tokens $tokens, + int $classIndex, + ): array { + $properties = []; + foreach ((new TokensAnalyzer($tokens))->getClassyElements() as $index => $element) { + if ($element['classIndex'] !== $classIndex) { + continue; + } + + if ('property' !== $element['type']) { + continue; + } + + $properties[substr($element['token']->getContent(), 1)] = $index; + } + + return $properties; + } + + private function getPropertyIndex( + Tokens $tokens, + array $properties, + int $assignmentIndex, + ): ?int { + $propertyNameIndex = $tokens->getPrevTokenOfKind($assignmentIndex, [[T_STRING]]); + assert(is_int($propertyNameIndex)); + + $propertyName = $tokens[$propertyNameIndex]->getContent(); + + foreach ($properties as $name => $index) { + if ($name !== $propertyName) { + continue; + } + + return $index; + } + + return null; + } + + private function getTokenOfKindSibling( + Tokens $tokens, + int $direction, + int $index, + array $tokenKinds, + ): int { + $index += $direction; + + while (!$tokens[$index]->equalsAny($tokenKinds)) { + $blockType = Tokens::detectBlockType($tokens[$index]); + + if (null !== $blockType) { + if ($blockType['isStart']) { + $index = $tokens->findBlockEnd($blockType['type'], $index); + } else { + $index = $tokens->findBlockStart($blockType['type'], $index); + } + } + + $index += $direction; + } + + return $index; + } + + private function getType( + Tokens $tokens, + ?int $variableIndex, + ): string { + if (null === $variableIndex) { + return ''; + } + + $index = $tokens->getPrevTokenOfKind($variableIndex, ['(', ',', [T_PRIVATE], [T_PROTECTED], [T_PUBLIC], [T_VAR], [CT::T_ATTRIBUTE_CLOSE]]); + assert(is_int($index)); + + $index = $tokens->getNextMeaningfulToken($index); + assert(is_int($index)); + + $type = ''; + while ($index < $variableIndex) { + $type .= $tokens[$index]->getContent(); + + $index = $tokens->getNextMeaningfulToken($index); + assert(is_int($index)); + } + + return $type; + } + + private function isDoctrineEntity( + Tokens $tokens, + int $index, + ): bool { + $phpDocIndex = $tokens->getPrevNonWhitespace($index); + assert(is_int($phpDocIndex)); + + if (!$tokens[$phpDocIndex]->isGivenKind(T_DOC_COMMENT)) { + return false; + } + + foreach ((new DocBlock($tokens[$phpDocIndex]->getContent()))->getAnnotations() as $annotation) { + if ((new class { + use _Match; + })::match('/\\*\\h+(@Document|@Entity|@Mapping\\\\Entity|@ODM\\\\Document|@ORM\\\\Entity|@ORM\\\\Mapping\\\\Entity)/', $annotation->getContent())) { + return true; + } + } + + return false; + } + + private function isPropertyToPromote( + Tokens $tokens, + ?int $propertyIndex, + bool $isDoctrineEntity, + ): bool { + if (null === $propertyIndex) { + return false; + } + + if (!$isDoctrineEntity) { + return true; + } + + $phpDocIndex = $tokens->getPrevTokenOfKind($propertyIndex, [[T_DOC_COMMENT]]); + assert(is_int($phpDocIndex)); + + $variableIndex = $tokens->getNextTokenOfKind($phpDocIndex, ['{', [T_VARIABLE]]); + + if ($variableIndex !== $propertyIndex) { + return true; + } + + $docBlock = new DocBlock($tokens[$phpDocIndex]->getContent()); + + return 0 === count($docBlock->getAnnotations()); + } + + private function promoteProperties( + Tokens $tokens, + int $classIndex, + Constructor $constructorAnalysis, + ): void { + $isDoctrineEntity = $this->isDoctrineEntity($tokens, $classIndex); + $properties = $this->getClassProperties($tokens, $classIndex); + + $constructorParameterNames = $constructorAnalysis->getConstructorParameterNames(); + $constructorPromotableParameters = $constructorAnalysis->getConstructorPromotableParameters(); + $constructorPromotableAssignments = $constructorAnalysis->getConstructorPromotableAssignments(); + + foreach ($constructorPromotableParameters as $constructorParameterIndex => $constructorParameterName) { + if (!array_key_exists($constructorParameterName, $constructorPromotableAssignments)) { + continue; + } + + $propertyIndex = $this->getPropertyIndex($tokens, $properties, $constructorPromotableAssignments[$constructorParameterName]); + + if (!$this->isPropertyToPromote($tokens, $propertyIndex, $isDoctrineEntity)) { + continue; + } + + $propertyType = $this->getType($tokens, $propertyIndex); + $parameterType = $this->getType($tokens, $constructorParameterIndex); + + if (!$this->typesAllowPromoting($propertyType, $parameterType)) { + continue; + } + + $assignedPropertyIndex = $tokens->getPrevTokenOfKind($constructorPromotableAssignments[$constructorParameterName], [[T_STRING]]); + $oldParameterName = $tokens[$constructorParameterIndex]->getContent(); + $newParameterName = '$' . $tokens[$assignedPropertyIndex]->getContent(); + if ($oldParameterName !== $newParameterName && in_array($newParameterName, $constructorParameterNames, true)) { + continue; + } + + $tokensToInsert = $this->removePropertyAndReturnTokensToInsert($tokens, $propertyIndex); + + $this->renameVariable($tokens, $constructorAnalysis->getConstructorIndex(), $oldParameterName, $newParameterName); + + $this->removeAssignment($tokens, $constructorPromotableAssignments[$constructorParameterName]); + $this->updateParameterSignature( + $tokens, + $constructorParameterIndex, + $tokensToInsert, + str_starts_with($propertyType, '?'), + ); + } + } + + private function removeAssignment( + Tokens $tokens, + int $variableAssignmentIndex, + ): void { + $thisIndex = $tokens->getPrevTokenOfKind($variableAssignmentIndex, [[T_VARIABLE]]); + assert(is_int($thisIndex)); + + $propertyEndIndex = $tokens->getNextTokenOfKind($variableAssignmentIndex, [';']); + assert(is_int($propertyEndIndex)); + + $tokens->clearRange($thisIndex + 1, $propertyEndIndex); + self::removeWithLinesIfPossible($tokens, $thisIndex); + } + + private function removePropertyAndReturnTokensToInsert( + Tokens $tokens, + ?int $propertyIndex, + ): array { + if (null === $propertyIndex) { + return [new Token([T_PUBLIC, 'public'])]; + } + + $visibilityIndex = $tokens->getPrevTokenOfKind($propertyIndex, [[T_PRIVATE], [T_PROTECTED], [T_PUBLIC], [T_VAR]]); + assert(is_int($visibilityIndex)); + + $prevPropertyIndex = $this->getTokenOfKindSibling($tokens, -1, $propertyIndex, ['{', '}', ';', ',']); + $nextPropertyIndex = $this->getTokenOfKindSibling($tokens, 1, $propertyIndex, [';', ',']); + + $removeFrom = $tokens->getTokenNotOfKindSibling($prevPropertyIndex, 1, [[T_WHITESPACE], [T_COMMENT]]); + assert(is_int($removeFrom)); + $removeTo = $nextPropertyIndex; + if ($tokens[$prevPropertyIndex]->equals(',')) { + $removeFrom = $prevPropertyIndex; + $removeTo = $propertyIndex; + } elseif ($tokens[$nextPropertyIndex]->equals(',')) { + $removeFrom = $tokens->getPrevMeaningfulToken($propertyIndex); + assert(is_int($removeFrom)); + $removeFrom++; + } + + $tokensToInsert = []; + for ($index = $removeFrom; $index <= $visibilityIndex - 1; $index++) { + $tokensToInsert[] = $tokens[$index]; + } + + $visibilityToken = $tokens[$visibilityIndex]; + if ($tokens[$visibilityIndex]->isGivenKind(T_VAR)) { + $visibilityToken = new Token([T_PUBLIC, 'public']); + } + $tokensToInsert[] = $visibilityToken; + + $tokens->clearRange($removeFrom + 1, $removeTo); + self::removeWithLinesIfPossible($tokens, $removeFrom); + + return $tokensToInsert; + } + + private function renameVariable( + Tokens $tokens, + int $constructorIndex, + string $oldName, + string $newName, + ): void { + $parenthesesOpenIndex = $tokens->getNextTokenOfKind($constructorIndex, ['(']); + assert(is_int($parenthesesOpenIndex)); + $parenthesesCloseIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $parenthesesOpenIndex); + $braceOpenIndex = $tokens->getNextTokenOfKind($parenthesesCloseIndex, ['{']); + assert(is_int($braceOpenIndex)); + $braceCloseIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $braceOpenIndex); + + for ($index = $parenthesesOpenIndex; $index < $braceCloseIndex; $index++) { + if ($tokens[$index]->equals([T_VARIABLE, $oldName])) { + $tokens[$index] = new Token([T_VARIABLE, $newName]); + } + } + } + + private function typesAllowPromoting( + string $propertyType, + string $parameterType, + ): bool { + if ('' === $propertyType) { + return true; + } + + if (str_starts_with($propertyType, '?')) { + $propertyType = substr($propertyType, 1); + } + + if (str_starts_with($parameterType, '?')) { + $parameterType = substr($parameterType, 1); + } + + return strtolower($propertyType) === strtolower($parameterType); + } + + private function updateParameterSignature( + Tokens $tokens, + int $constructorParameterIndex, + array $tokensToInsert, + bool $makeTypeNullable, + ): void { + $prevElementIndex = $tokens->getPrevTokenOfKind($constructorParameterIndex, ['(', ',', [CT::T_ATTRIBUTE_CLOSE]]); + assert(is_int($prevElementIndex)); + + $propertyStartIndex = $tokens->getNextMeaningfulToken($prevElementIndex); + assert(is_int($propertyStartIndex)); + + foreach ($tokensToInsert as $index => $token) { + if ($token->isGivenKind(T_PUBLIC)) { + $tokensToInsert[$index] = new Token([CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PUBLIC, $token->getContent()]); + } elseif ($token->isGivenKind(T_PROTECTED)) { + $tokensToInsert[$index] = new Token([CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PROTECTED, $token->getContent()]); + } elseif ($token->isGivenKind(T_PRIVATE)) { + $tokensToInsert[$index] = new Token([CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PRIVATE, $token->getContent()]); + } + } + $tokensToInsert[] = new Token([T_WHITESPACE, ' ']); + + if ($makeTypeNullable && !$tokens[$propertyStartIndex]->isGivenKind(CT::T_NULLABLE_TYPE)) { + $tokensToInsert[] = new Token([CT::T_NULLABLE_TYPE, '?']); + } + + $this->tokensToInsert[$propertyStartIndex] = $tokensToInsert; + } +} diff --git a/Fixers.php b/Fixers.php new file mode 100644 index 0000000..4404282 --- /dev/null +++ b/Fixers.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers; + +use DirectoryIterator; +use Generator; +use IteratorAggregate; +use PhpCsFixer\Fixer\FixerInterface; +use Symfony\Component\Finder\Finder; +use Vairogs\Component\Functions\Preg\_Match; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\AbstractFixer; + +use function assert; +use function class_exists; +use function file_get_contents; +use function in_array; +use function sort; + +final class Fixers implements IteratorAggregate +{ + public function getIterator(): Generator + { + $classNames = []; + foreach (new DirectoryIterator(__DIR__ . '/Fixer') as $fileInfo) { + $fileName = $fileInfo->getBasename('.php'); + if (in_array($fileName, ['.', '..', ], true)) { + continue; + } + $classNames[] = __NAMESPACE__ . '\\Fixer\\' . $fileName; + } + + sort($classNames); + + foreach ($classNames as $className) { + $fixer = new $className(); + assert($fixer instanceof FixerInterface); + + yield $fixer; + } + } + + public static function getFixers(): array + { + $finder = new Finder(); + $finder->files()->in(__DIR__ . '/Fixer/')->name('*.php'); + $files = []; + + $match = new class { + use _Match; + }; + foreach ($finder as $file) { + $fileContents = file_get_contents($file->getRealPath()); + + if ($match::match('/namespace\s+(.+?);/', $fileContents, $namespaceMatches) + && $match::match('/class\s+(\w+)/', $fileContents, $classMatches)) { + $className = $classMatches[1]; + $fullClassName = $namespaceMatches[1] . '\\' . $className; + + if (class_exists($fullClassName)) { + $files[] = $fullClassName; + } + } + } + + $fixers = []; + foreach ($files as $fixer) { + $fixers[AbstractFixer::getNameForClass($fixer)] = true; + } + + return $fixers; + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4088645 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2023+, Dāvis Zālītis (k0d3r1s) + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/PhpCsFixer/AbstractFixer.php b/PhpCsFixer/AbstractFixer.php new file mode 100644 index 0000000..1825b6b --- /dev/null +++ b/PhpCsFixer/AbstractFixer.php @@ -0,0 +1,321 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\PhpCsFixer; + +use InvalidArgumentException; +use LogicException; +use PhpCsFixer\Fixer\FixerInterface; +use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface; +use PhpCsFixer\FixerDefinition\CodeSample; +use PhpCsFixer\FixerDefinition\FixerDefinition; +use PhpCsFixer\FixerDefinition\FixerDefinitionInterface; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use PhpCsFixer\WhitespacesFixerConfig; +use SplFileInfo; +use Vairogs\Component\Functions\Preg\_Match; +use Vairogs\Component\Functions\Preg\_Replace; +use Vairogs\Component\Functions\Text\_SnakeCaseFromCamelCase; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Analyzer; + +use function array_map; +use function array_pop; +use function assert; +use function count; +use function explode; +use function is_array; +use function is_int; +use function ltrim; +use function sprintf; +use function str_replace; +use function strlen; +use function strrchr; +use function substr; + +use const T_CLASS; +use const T_EXTENDS; +use const T_IMPLEMENTS; +use const T_NS_SEPARATOR; +use const T_OPEN_TAG; +use const T_STRING; +use const T_USE; +use const T_WHITESPACE; + +/** + * @internal + */ +abstract class AbstractFixer implements FixerInterface, WhitespacesAwareFixerInterface +{ + public const string PREFIX = 'VairogsPhpCsFixerCustomFixers'; + + protected WhitespacesFixerConfig $whitespacesConfig; + + abstract public function getDocumentation(): string; + + abstract public function getSampleCode(): string; + + final public function fix( + SplFileInfo $file, + Tokens $tokens, + ): void { + if ($tokens->count() > 0 && $this->isCandidate($tokens) && $this->supports($file)) { + $this->applyFix($file, $tokens); + } + } + + final public function getDefinition(): FixerDefinitionInterface + { + return new FixerDefinition( + $this->getDocumentation(), + array_map( + static fn (?array $configutation = null) => new CodeSample($this->getSampleCode(), $configutation), + [[]], + ), + ); + } + + final public function getName(): string + { + return self::getNameForClass(static::class); + } + + public function getPriority(): int + { + return 30; + } + + public function isCandidate( + Tokens $tokens, + ): bool { + return true; + } + + public function isRisky(): bool + { + return false; + } + + public function setWhitespacesConfig( + WhitespacesFixerConfig $config, + ): void { + if (!$this instanceof WhitespacesAwareFixerInterface) { + throw new LogicException('Cannot run method for class not implementing "PhpCsFixer\Fixer\WhitespacesAwareFixerInterface".'); + } + + $this->whitespacesConfig = $config; + } + + final public function supports( + SplFileInfo $file, + ): bool { + return true; + } + + public static function calculateTrailingWhitespaceIndent( + Token $token, + ): string { + if (!$token->isWhitespace()) { + throw new InvalidArgumentException(sprintf('The given token must be whitespace, got "%s".', $token->getName())); + } + + $str = strrchr(str_replace(["\r\n", "\r"], "\n", $token->getContent()), "\n"); + + return false === $str ? '' : ltrim($str, "\n"); + } + + final public static function getNameForClass( + string $class, + ): string { + $parts = explode('\\', $class); + $name = substr($parts[count($parts) - 1], 0, -strlen('Fixer')); + + return sprintf('%s/%s', self::PREFIX, (new class { + use _SnakeCaseFromCamelCase; + })->snakeCaseFromCamelCase($name)); + } + + public static function removeWithLinesIfPossible( + Tokens $tokens, + int $index, + ): void { + if (self::isTokenOnlyMeaningfulInLine($tokens, $index)) { + $prev = $tokens->getNonEmptySibling($index, -1); + assert(is_int($prev)); + $wasNewlineRemoved = self::handleWhitespaceBefore($tokens, $prev); + + $next = $tokens->getNonEmptySibling($index, 1); + if (null !== $next) { + self::handleWhitespaceAfter($tokens, $next, $wasNewlineRemoved); + } + } + + $tokens->clearTokenAndMergeSurroundingWhitespace($index); + } + + abstract protected function applyFix( + SplFileInfo $file, + Tokens $tokens, + ): void; + + protected function analyze( + Tokens $tokens, + ): Analyzer { + return new Analyzer($tokens); + } + + protected function extendsClass( + Tokens $tokens, + array|string $fqcn, + ): bool { + $fqcn = is_array($fqcn) ? $fqcn : explode('\\', $fqcn); + + return $this->hasUseStatements($tokens, $fqcn) + && null !== $tokens->findSequence([ + [T_CLASS], + [T_STRING], + [T_EXTENDS], + [T_STRING, array_pop($fqcn)], + ]); + } + + protected function getComments( + Tokens $tokens, + ): array { + $comments = []; + foreach ($tokens as $index => $token) { + if ($token->isComment()) { + $comments[$index] = $token; + } + } + + return $comments; + } + + protected function getUseStatements( + Tokens $tokens, + array|string $fqcn, + ): ?array { + $fqcnArray = is_array($fqcn) ? $fqcn : explode('\\', $fqcn); + $sequence = [[T_USE]]; + foreach ($fqcnArray as $component) { + $sequence[] = [T_STRING, $component]; + $sequence[] = [T_NS_SEPARATOR]; + } + $sequence[count($sequence) - 1] = ';'; + + return $tokens->findSequence($sequence); + } + + protected function hasUseStatements( + Tokens $tokens, + $fqcn, + ): bool { + return null !== $this->getUseStatements($tokens, $fqcn); + } + + protected function implementsInterface( + Tokens $tokens, + array|string $fqcn, + ): bool { + $fqcn = is_array($fqcn) ? $fqcn : explode('\\', $fqcn); + + return $this->hasUseStatements($tokens, $fqcn) + && null !== $tokens->findSequence([ + [T_CLASS], + [T_STRING], + [T_IMPLEMENTS], + [T_STRING, array_pop($fqcn)], + ]); + } + + protected static function handleWhitespaceAfter( + Tokens $tokens, + int $index, + bool $wasNewlineRemoved, + ): void { + $pattern = $wasNewlineRemoved ? '/^\\h+/' : '/^\\h*\\R/'; + $newContent = (new class { + use _Replace; + })::replace($pattern, '', $tokens[$index]->getContent()); + $tokens->ensureWhitespaceAtIndex($index, 0, $newContent); + } + + protected static function handleWhitespaceBefore( + Tokens $tokens, + int $index, + ): bool { + if (!$tokens[$index]->isGivenKind(T_WHITESPACE)) { + return false; + } + + $replace = new class { + use _Replace; + }; + + $withoutTrailingSpaces = $replace::replace('/\\h+$/', '', $tokens[$index]->getContent()); + $withoutNewline = $replace::replace('/\\R$/', '', $withoutTrailingSpaces, 1); + $tokens->ensureWhitespaceAtIndex($index, 0, $withoutNewline); + + return $withoutTrailingSpaces !== $withoutNewline; + } + + protected static function hasMeaningTokenInLineAfter( + Tokens $tokens, + int $index, + ): bool { + $next = $tokens->getNonEmptySibling($index, 1); + + return null !== $next + && (!$tokens[$next]->isGivenKind(T_WHITESPACE) + || !(new class { + use _Match; + })::match('/\\R/', $tokens[$next]->getContent())); + } + + protected static function hasMeaningTokenInLineBefore( + Tokens $tokens, + int $index, + ): bool { + $prev = $tokens->getNonEmptySibling($index, -1); + assert(is_int($prev)); + + if (!$tokens[$prev]->isGivenKind([T_OPEN_TAG, T_WHITESPACE])) { + return true; + } + + $match = new class { + use _Match; + }; + + if ($tokens[$prev]->isGivenKind(T_OPEN_TAG) && !$match::match('/\\R$/', $tokens[$prev]->getContent())) { + return true; + } + + if (!$match::match('/\\R/', $tokens[$prev]->getContent())) { + $prevPrev = $tokens->getNonEmptySibling($prev, -1); + assert(is_int($prevPrev)); + + if (!$tokens[$prevPrev]->isGivenKind(T_OPEN_TAG) || !$match::match('/\\R$/', $tokens[$prevPrev]->getContent())) { + return true; + } + } + + return false; + } + + protected static function isTokenOnlyMeaningfulInLine( + Tokens $tokens, + int $index, + ): bool { + return !self::hasMeaningTokenInLineBefore($tokens, $index) && !self::hasMeaningTokenInLineAfter($tokens, $index); + } +} diff --git a/PhpCsFixer/Analyzer/Analyzer.php b/PhpCsFixer/Analyzer/Analyzer.php new file mode 100644 index 0000000..cf2a97e --- /dev/null +++ b/PhpCsFixer/Analyzer/Analyzer.php @@ -0,0 +1,883 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer; + +use Exception; +use InvalidArgumentException; +use PhpCsFixer\Tokenizer\CT; +use PhpCsFixer\Tokenizer\Tokens; +use PhpCsFixer\Tokenizer\TokensAnalyzer; +use Vairogs\Component\Functions\Preg\_Match; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element\Argument; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element\ArrayElement; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element\CaseElement; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element\Constructor; +use Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element\SwitchElement; + +use function array_keys; +use function assert; +use function call_user_func_array; +use function count; +use function current; +use function end; +use function explode; +use function in_array; +use function is_array; +use function is_int; +use function ksort; +use function mb_strlen; +use function mb_strpos; +use function mb_strtolower; +use function reset; +use function sprintf; + +use const T_ARRAY; +use const T_ATTRIBUTE; +use const T_CASE; +use const T_CLASS; +use const T_CONST; +use const T_DEFAULT; +use const T_DOUBLE_ARROW; +use const T_ENDSWITCH; +use const T_FUNCTION; +use const T_ISSET; +use const T_PRIVATE; +use const T_PROTECTED; +use const T_STATIC; +use const T_STRING; +use const T_SWITCH; +use const T_VARIABLE; + +/** + * @internal + */ +final class Analyzer +{ + public const int TYPINT_OPTIONAL = 10022; + public const int TYPINT_DOUBLE_DOTS = 10025; + + private TokensAnalyzer $analyzer; + + public function __construct( + private readonly Tokens $tokens, + ) { + $this->analyzer = new TokensAnalyzer($tokens); + } + + public function __call( + string $name, + array $arguments, + ) { + return call_user_func_array([$this->analyzer, $name], $arguments); + } + + /** + * @throws Exception + */ + public function endOfTheStatement( + ?int $index, + ): ?int { + return $this->findBlockEndMatchingOpeningToken($index, '}', '{'); + } + + public function findAllSequences( + array $seqs, + mixed $start = null, + mixed $end = null, + ): array { + $sequences = []; + + foreach ($seqs as $seq) { + $index = $start ?: 0; + + do { + $extract = $this->tokens->findSequence($seq, (int) $index, $end); + + if (null !== $extract) { + $keys = array_keys($extract); + $index = end($keys) + 1; + $sequences[reset($keys)] = $extract; + } + } while (null !== $extract); + } + + ksort($sequences); + + return $sequences; + } + + public function findNonAbstractConstructor( + int $classIndex, + ): ?Constructor { + if (!$this->tokens[$classIndex]->isGivenKind(T_CLASS)) { + throw new InvalidArgumentException(sprintf('Index %d is not a class.', $classIndex)); + } + + foreach ($this->analyzer->getClassyElements() as $index => $element) { + if ($element['classIndex'] !== $classIndex) { + continue; + } + + if (!$this->isConstructor($index, $element)) { + continue; + } + + $constructorAttributes = $this->analyzer->getMethodAttributes($index); + if ($constructorAttributes['abstract']) { + return null; + } + + return new Constructor($this->tokens, $index); + } + + return null; + } + + public function getArrayElements( + int $index, + ): array { + $startIndex = null; + $endIndex = null; + + if ($this->tokens[$index]->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_OPEN)) { + $startIndex = $this->tokens->getNextMeaningfulToken($index); + $endIndex = $this->tokens->getPrevMeaningfulToken($this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $index)); + } elseif ($this->tokens[$index]->isGivenKind(T_ARRAY)) { + $arrayOpenBraceIndex = $this->tokens->getNextTokenOfKind($index, ['(']); + $startIndex = $this->tokens->getNextMeaningfulToken($arrayOpenBraceIndex); + $endIndex = $this->tokens->getPrevMeaningfulToken($this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $arrayOpenBraceIndex)); + } + + if (!is_int($startIndex) || !is_int($endIndex)) { + throw new InvalidArgumentException(sprintf('Index %d is not an array.', $index)); + } + + return $this->getElementsForArrayContent($startIndex, $endIndex); + } + + public function getBeginningOfTheLine( + int $index, + ): ?int { + for ($i = $index; $i >= 0; $i--) { + if (false !== mb_strpos($this->tokens[$i]->getContent(), "\n")) { + return $i; + } + } + + return null; + } + + /** + * @throws Exception + */ + public function getClosingAttribute( + int $index, + ): ?int { + return $this->findBlockEndMatchingOpeningToken($index, CT::T_ATTRIBUTE_CLOSE, T_ATTRIBUTE); + } + + /** + * @throws Exception + */ + public function getClosingBracket( + int $index, + ): ?int { + return $this->findBlockEndMatchingOpeningToken($index, ']', '['); + } + + /** + * @throws Exception + */ + public function getClosingCurlyBracket( + int $index, + ): ?int { + return $this->findBlockEndMatchingOpeningToken($index, '}', '{'); + } + + /** + * @throws Exception + */ + public function getClosingParenthesis( + int $index, + ): ?int { + return $this->findBlockEndMatchingOpeningToken($index, ')', '('); + } + + public function getElements( + ?int $startIndex = null, + ): array { + if (null === $startIndex) { + $index = $startIndex; + foreach ($this->tokens as $index => $token) { + if (!$token->isClassy()) { + continue; + } + + $index = $this->tokens->getNextTokenOfKind($index, ['{']); + + break; + } + $startIndex = $index; + } + + $startIndex++; + $elements = []; + + while (true) { + $element = [ + 'start' => $startIndex, + 'visibility' => 'public', + 'static' => false, + ]; + + for ($i = $startIndex;; $i++) { + $token = $this->tokens[$i]; + + if ('}' === $token->getContent()) { + return $elements; + } + + if ($token->isGivenKind(T_STATIC)) { + $element['static'] = true; + + continue; + } + + if ($token->isGivenKind([T_PROTECTED, T_PRIVATE])) { + $element['visibility'] = mb_strtolower($token->getContent()); + + continue; + } + + if (!$token->isGivenKind([CT::T_USE_TRAIT, T_CONST, T_VARIABLE, T_FUNCTION])) { + continue; + } + + $type = $this->detectElementType($i); + $element['type'] = $type; + + switch ($type) { + case 'method': + $element['methodName'] = $this->tokens[$this->tokens->getNextMeaningfulToken($i)]->getContent(); + + break; + + case 'property': + $element['propertyName'] = $token->getContent(); + + break; + } + $element['end'] = $this->findElementEnd($i); + + break; + } + + $elements[] = $element; + $startIndex = $element['end'] + 1; + } + } + + public function getEndOfTheLine( + int $index, + ): ?int { + for ($i = $index; $i < $this->tokens->count(); $i++) { + if (false !== mb_strpos($this->tokens[$i]->getContent(), "\n")) { + return $i; + } + } + + return null; + } + + public function getFunctionArguments( + ?int $index, + ): array { + $argumentsRange = $this->getArgumentsRange($index); + if (null === $argumentsRange) { + return []; + } + + [$argumentStartIndex, $argumentsEndIndex] = $argumentsRange; + + $arguments = []; + $index = $currentArgumentStart = $argumentStartIndex; + while ($index < $argumentsEndIndex) { + $blockType = Tokens::detectBlockType($this->tokens[$index]); + if (null !== $blockType && $blockType['isStart']) { + $index = $this->tokens->findBlockEnd($blockType['type'], $index); + continue; + } + + $index = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($index)); + + if (!$this->tokens[$index]->equals(',')) { + continue; + } + + $currentArgumentEnd = $this->tokens->getPrevMeaningfulToken($index); + assert(is_int($currentArgumentEnd)); + + $arguments[] = $this->createArgumentAnalysis($currentArgumentStart, $currentArgumentEnd); + + $currentArgumentStart = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($currentArgumentStart)); + } + + $arguments[] = $this->createArgumentAnalysis($currentArgumentStart, $argumentsEndIndex); + + return $arguments; + } + + public function getLineIndentation( + int $index, + ): string { + $start = $this->getBeginningOfTheLine($index); + $token = $this->tokens[$start]; + $parts = explode("\n", $token->getContent()); + + return end($parts); + } + + /** + * @throws Exception + */ + public function getMethodArguments( + int $index, + ): array { + $methodName = $this->tokens->getNextMeaningfulToken($index); + $openParenthesis = $this->tokens->getNextMeaningfulToken($methodName); + $closeParenthesis = $this->getClosingParenthesis($openParenthesis); + + $arguments = []; + $match = new class { + use _Match; + }; + + for ($position = $openParenthesis + 1; $position < $closeParenthesis; $position++) { + $token = $this->tokens[$position]; + + if ($token->isWhitespace()) { + continue; + } + + $argumentType = null; + $argumentName = $position; + $argumentAsDefault = false; + $argumentNullable = false; + + if (!$match::match('/^\$.+/', $this->tokens[$argumentName]->getContent())) { + do { + if (!$this->tokens[$argumentName]->isWhitespace()) { + $argumentType .= $this->tokens[$argumentName]->getContent(); + } + + $argumentName++; + } while (!$match::match('/^\$.+/', $this->tokens[$argumentName]->getContent())); + } + + $next = $this->tokens->getNextMeaningfulToken($argumentName); + + if ('=' === $this->tokens[$next]->getContent()) { + $argumentAsDefault = true; + $value = $this->tokens->getNextMeaningfulToken($next); + $argumentNullable = 'null' === $this->tokens[$value]->getContent(); + } + + $arguments[$position] = [ + 'type' => $argumentType, + 'name' => $this->tokens[$argumentName]->getContent(), + 'nullable' => $argumentNullable, + 'asDefault' => $argumentAsDefault, + ]; + + $nextComma = $this->getNextComma($position); + + if (null === $nextComma) { + return $arguments; + } + + $position = $nextComma; + } + + return $arguments; + } + + /** + * @throws Exception + */ + public function getNextComma( + ?int $index, + ): ?int { + return $this->findBlockEndMatchingOpeningToken($index, ',', ['(', '[', '{']); + } + + /** + * @throws Exception + */ + public function getNextSemiColon( + ?int $index, + ): ?int { + return $this->findBlockEndMatchingOpeningToken($index, ';', ['(', '[', '{']); + } + + /** + * @throws Exception + */ + public function getNumberOfArguments( + int $index, + ): int { + return count($this->getMethodArguments($index)); + } + + /** + * @throws Exception + */ + public function getReturnedType( + int $index, + ): array|string|null { + if (!$this->tokens[$index]->isGivenKind(T_FUNCTION)) { + throw new Exception(sprintf('Expected token: T_FUNCTION Token %d id contains %s.', $index, $this->tokens[$index]->getContent())); + } + + $methodName = $this->tokens->getNextMeaningfulToken($index); + $openParenthesis = $this->tokens->getNextMeaningfulToken($methodName); + $closeParenthesis = $this->getClosingParenthesis($openParenthesis); + + $next = $this->tokens->getNextMeaningfulToken($closeParenthesis); + + if (null === $next) { + return null; + } + + if (!$this->tokens[$next]->isGivenKind(self::TYPINT_DOUBLE_DOTS)) { + return null; + } + + $next = $this->tokens->getNextMeaningfulToken($next); + + if (null === $next) { + return null; + } + + $optionnal = $this->tokens[$next]->isGivenKind(self::TYPINT_OPTIONAL); + + $next = $optionnal + ? $this->tokens->getNextMeaningfulToken($next) + : $next; + + do { + $return = $this->tokens[$next]->getContent(); + $next++; + + if ($this->tokens[$next]->isWhitespace() || ';' === $this->tokens[$next]->getContent()) { + return $optionnal + ? [$return, null] + : $return; + } + } while (!in_array($this->tokens[$index]->getContent(), ['{', ';'], true)); + + return null; + } + + public function getSizeOfTheLine( + int $index, + ): int { + $start = $this->getBeginningOfTheLine($index); + $end = $this->getEndOfTheLine($index); + $size = 0; + + $parts = explode("\n", $this->tokens[$start]->getContent()); + $size += mb_strlen(end($parts)); + + $parts = explode("\n", $this->tokens[$end]->getContent()); + $size += mb_strlen(current($parts)); + + for ($i = $start + 1; $i < $end; $i++) { + $size += mb_strlen($this->tokens[$i]->getContent()); + } + + return $size; + } + + public function getSwitchAnalysis( + int $switchIndex, + ): SwitchElement { + if (!$this->tokens[$switchIndex]->isGivenKind(T_SWITCH)) { + throw new InvalidArgumentException(sprintf('Index %d is not "switch".', $switchIndex)); + } + + $casesStartIndex = $this->getCasesStart($switchIndex); + $casesEndIndex = $this->getCasesEnd($casesStartIndex); + + $cases = []; + $index = $casesStartIndex; + while ($index < $casesEndIndex) { + $index = $this->getNextSameLevelToken($index); + + if (!$this->tokens[$index]->isGivenKind([T_CASE, T_DEFAULT])) { + continue; + } + + $cases[] = $this->getCaseAnalysis($index); + } + + return new SwitchElement($casesStartIndex, $casesEndIndex, $cases); + } + + /** + * @throws Exception + */ + public function isInsideSwitchCase( + int $index, + ): bool { + $switches = $this->findAllSequences([[[T_SWITCH]]]); + $intervals = []; + + foreach ($switches as $i => $switch) { + $start = $this->tokens->getNextTokenOfKind($i, ['{']); + $end = $this->getClosingCurlyBracket($start); + + $intervals[] = [$start, $end]; + } + + foreach ($intervals as $interval) { + [$start, $end] = $interval; + + if ($index >= $start && $index <= $end) { + return true; + } + } + + return false; + } + + private function createArgumentAnalysis( + int $startIndex, + int $endIndex, + ): Argument { + $isConstant = true; + + for ($index = $startIndex; $index <= $endIndex; $index++) { + if ($this->tokens[$index]->isGivenKind(T_VARIABLE)) { + $isConstant = false; + } + + if ($this->tokens[$index]->equals('(')) { + $prevParenthesisIndex = $this->tokens->getPrevMeaningfulToken($index); + assert(is_int($prevParenthesisIndex)); + + if (!$this->tokens[$prevParenthesisIndex]->isGivenKind(T_ARRAY)) { + $isConstant = false; + } + } + } + + return new Argument($startIndex, $endIndex, $isConstant); + } + + private function createArrayElementAnalysis( + int $startIndex, + int $endIndex, + ): ArrayElement { + $index = $startIndex; + while ($endIndex > $index = $this->nextCandidateIndex($index)) { + if (!$this->tokens[$index]->isGivenKind(T_DOUBLE_ARROW)) { + continue; + } + + $keyEndIndex = $this->tokens->getPrevMeaningfulToken($index); + assert(is_int($keyEndIndex)); + + $valueStartIndex = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($valueStartIndex)); + + return new ArrayElement($startIndex, $keyEndIndex, $valueStartIndex, $endIndex); + } + + return new ArrayElement(null, null, $startIndex, $endIndex); + } + + private function detectElementType( + int $index, + ): array|string { + $token = $this->tokens[$index]; + + if ($token->isGivenKind(CT::T_USE_TRAIT)) { + return 'use_trait'; + } + + if ($token->isGivenKind(T_CONST)) { + return 'constant'; + } + + if ($token->isGivenKind(T_VARIABLE)) { + return 'property'; + } + + $nameToken = $this->tokens[$this->tokens->getNextMeaningfulToken($index)]; + + if ($nameToken->equals([T_STRING, '__construct'], false)) { + return 'construct'; + } + + if ($nameToken->equals([T_STRING, '__destruct'], false)) { + return 'destruct'; + } + + if ( + $nameToken->equalsAny([ + [T_STRING, 'setUpBeforeClass'], + [T_STRING, 'tearDownAfterClass'], + [T_STRING, 'setUp'], + [T_STRING, 'tearDown'], + ], false) + ) { + return ['phpunit', mb_strtolower($nameToken->getContent())]; + } + + if (0 === mb_strpos($nameToken->getContent(), '__')) { + return 'magic'; + } + + return 'method'; + } + + /** + * @throws Exception + */ + private function findBlockEndMatchingOpeningToken( + ?int $index, + string|int $closingToken, + string|int|array $openingToken, + ): ?int { + do { + $index = $this->tokens->getNextMeaningfulToken($index); + + if (null === $index) { + return null; + } + + if (is_array($openingToken)) { + foreach ($openingToken as $opening) { + if ($opening === $this->tokens[$index]->getContent()) { + $index = $this->getClosingMatchingToken($index, $opening); + break; + } + } + } elseif ($openingToken === $this->tokens[$index]->getContent()) { + $index = $this->getClosingMatchingToken($index, $openingToken); + } + } while ($closingToken !== $this->tokens[$index]->getContent()); + + return $index; + } + + private function findElementEnd( + ?int $index, + ): int { + $index = $this->tokens->getNextTokenOfKind($index, ['{', ';']); + + if ('{' === $this->tokens[$index]->getContent()) { + $index = $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $index); + } + + $index++; + while ($index < count($this->tokens) && ($this->tokens[$index]->isWhitespace(" \t") || $this->tokens[$index]->isComment())) { + $index++; + } + + return $this->tokens[$index - 1]->isWhitespace() ? $index - 2 : $index - 1; + } + + private function getArgumentsRange( + int $index, + ): ?array { + if (!$this->tokens[$index]->isGivenKind([T_ISSET, T_STRING])) { + throw new InvalidArgumentException(sprintf('Index %d is not a function.', $index)); + } + + $openParenthesis = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($openParenthesis)); + if (!$this->tokens[$openParenthesis]->equals('(')) { + throw new InvalidArgumentException(sprintf('Index %d is not a function.', $index)); + } + + $closeParenthesis = $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesis); + + $argumentsEndIndex = $this->tokens->getPrevMeaningfulToken($closeParenthesis); + assert(is_int($argumentsEndIndex)); + + if ($openParenthesis === $argumentsEndIndex) { + return null; + } + + if ($this->tokens[$argumentsEndIndex]->equals(',')) { + $argumentsEndIndex = $this->tokens->getPrevMeaningfulToken($argumentsEndIndex); + assert(is_int($argumentsEndIndex)); + } + + $argumentStartIndex = $this->tokens->getNextMeaningfulToken($openParenthesis); + assert(is_int($argumentStartIndex)); + + return [$argumentStartIndex, $argumentsEndIndex]; + } + + private function getCaseAnalysis( + int $index, + ): CaseElement { + while ($index < $this->tokens->count()) { + $index = $this->getNextSameLevelToken($index); + + if ($this->tokens[$index]->equalsAny([':', ';'])) { + break; + } + } + + return new CaseElement($index); + } + + private function getCasesEnd( + int $casesStartIndex, + ): int { + if ($this->tokens[$casesStartIndex]->equals('{')) { + return $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $casesStartIndex); + } + + $index = $casesStartIndex; + while ($index < $this->tokens->count()) { + $index = $this->getNextSameLevelToken($index); + + if ($this->tokens[$index]->isGivenKind(T_ENDSWITCH)) { + break; + } + } + + $afterEndswitchIndex = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($afterEndswitchIndex)); + + return $this->tokens[$afterEndswitchIndex]->equals(';') ? $afterEndswitchIndex : $index; + } + + private function getCasesStart( + int $switchIndex, + ): int { + $parenthesisStartIndex = $this->tokens->getNextMeaningfulToken($switchIndex); + assert(is_int($parenthesisStartIndex)); + $parenthesisEndIndex = $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $parenthesisStartIndex); + + $casesStartIndex = $this->tokens->getNextMeaningfulToken($parenthesisEndIndex); + assert(is_int($casesStartIndex)); + + return $casesStartIndex; + } + + /** + * @throws Exception + */ + private function getClosingMatchingToken( + int $index, + string $openingToken, + ): ?int { + return match ($openingToken) { + '(' => $this->getClosingParenthesis($index), + '[' => $this->getClosingBracket($index), + '{' => $this->getClosingCurlyBracket($index), + default => throw new Exception(sprintf('Unsupported opening token: %s', $openingToken)), + }; + } + + private function getElementsForArrayContent( + ?int $startIndex, + int $endIndex, + ): array { + $elements = []; + + $index = $startIndex; + while ($endIndex >= $index = $this->nextCandidateIndex($index)) { + if (!$this->tokens[$index]->equals(',')) { + continue; + } + + $elementEndIndex = $this->tokens->getPrevMeaningfulToken($index); + assert(is_int($elementEndIndex)); + + $elements[] = $this->createArrayElementAnalysis($startIndex, $elementEndIndex); + + $startIndex = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($startIndex)); + } + + if ($startIndex <= $endIndex) { + $elements[] = $this->createArrayElementAnalysis($startIndex, $endIndex); + } + + return $elements; + } + + private function getNextSameLevelToken( + ?int $index, + ): int { + $index = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($index)); + + if ($this->tokens[$index]->isGivenKind(T_SWITCH)) { + return $this->getSwitchAnalysis($index)->getCasesEnd(); + } + + $blockType = Tokens::detectBlockType($this->tokens[$index]); + if (null !== $blockType && $blockType['isStart']) { + return $this->tokens->findBlockEnd($blockType['type'], $index) + 1; + } + + return $index; + } + + private function isConstructor( + int $index, + array $element, + ): bool { + if ('method' !== $element['type']) { + return false; + } + + $functionNameIndex = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($functionNameIndex)); + + return $this->tokens[$functionNameIndex]->equals([T_STRING, '__construct'], false); + } + + private function nextCandidateIndex( + int $index, + ): int { + if ($this->tokens[$index]->equals('{')) { + return $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $index) + 1; + } + + if ($this->tokens[$index]->equals('(')) { + return $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $index) + 1; + } + + if ($this->tokens[$index]->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_OPEN)) { + return $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $index) + 1; + } + + if ($this->tokens[$index]->isGivenKind(T_ARRAY)) { + $arrayOpenBraceIndex = $this->tokens->getNextTokenOfKind($index, ['(']); + assert(is_int($arrayOpenBraceIndex)); + + return $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $arrayOpenBraceIndex) + 1; + } + + return $index + 1; + } +} diff --git a/PhpCsFixer/Analyzer/Element/Argument.php b/PhpCsFixer/Analyzer/Element/Argument.php new file mode 100644 index 0000000..981ba58 --- /dev/null +++ b/PhpCsFixer/Analyzer/Element/Argument.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element; + +/** + * @internal + */ +final readonly class Argument +{ + public function __construct( + private int $startIndex, + private int $endIndex, + private bool $isConstant, + ) { + } + + public function getEndIndex(): int + { + return $this->endIndex; + } + + public function getStartIndex(): int + { + return $this->startIndex; + } + + public function isConstant(): bool + { + return $this->isConstant; + } +} diff --git a/PhpCsFixer/Analyzer/Element/ArrayElement.php b/PhpCsFixer/Analyzer/Element/ArrayElement.php new file mode 100644 index 0000000..299ac92 --- /dev/null +++ b/PhpCsFixer/Analyzer/Element/ArrayElement.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element; + +/** + * @internal + */ +final readonly class ArrayElement +{ + public function __construct( + private ?int $keyStartIndex, + private ?int $keyEndIndex, + private int $valueStartIndex, + private int $valueEndIndex, + ) { + } + + public function getKeyEndIndex(): ?int + { + return $this->keyEndIndex; + } + + public function getKeyStartIndex(): ?int + { + return $this->keyStartIndex; + } + + public function getValueEndIndex(): int + { + return $this->valueEndIndex; + } + + public function getValueStartIndex(): int + { + return $this->valueStartIndex; + } +} diff --git a/PhpCsFixer/Analyzer/Element/CaseElement.php b/PhpCsFixer/Analyzer/Element/CaseElement.php new file mode 100644 index 0000000..d1bcd31 --- /dev/null +++ b/PhpCsFixer/Analyzer/Element/CaseElement.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element; + +/** + * @internal + */ +final readonly class CaseElement +{ + public function __construct( + private int $colonIndex, + ) { + } + + public function getColonIndex(): int + { + return $this->colonIndex; + } +} diff --git a/PhpCsFixer/Analyzer/Element/Constructor.php b/PhpCsFixer/Analyzer/Element/Constructor.php new file mode 100644 index 0000000..558f556 --- /dev/null +++ b/PhpCsFixer/Analyzer/Element/Constructor.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element; + +use PhpCsFixer\Tokenizer\CT; +use PhpCsFixer\Tokenizer\Tokens; + +use function array_flip; +use function array_key_exists; +use function assert; +use function is_int; + +use const T_CALLABLE; +use const T_ELLIPSIS; +use const T_STRING; +use const T_VARIABLE; + +/** + * @internal + */ +final readonly class Constructor +{ + public function __construct( + private Tokens $tokens, + private int $constructorIndex, + ) { + } + + public function getConstructorIndex(): int + { + return $this->constructorIndex; + } + + public function getConstructorParameterNames(): array + { + $openParenthesis = $this->tokens->getNextTokenOfKind($this->constructorIndex, ['(']); + assert(is_int($openParenthesis)); + $closeParenthesis = $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesis); + + $constructorParameterNames = []; + for ($index = $openParenthesis + 1; $index < $closeParenthesis; $index++) { + if (!$this->tokens[$index]->isGivenKind(T_VARIABLE)) { + continue; + } + + $constructorParameterNames[] = $this->tokens[$index]->getContent(); + } + + return $constructorParameterNames; + } + + public function getConstructorPromotableAssignments(): array + { + $openParenthesis = $this->tokens->getNextTokenOfKind($this->constructorIndex, ['(']); + assert(is_int($openParenthesis)); + $closeParenthesis = $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesis); + + $openBrace = $this->tokens->getNextTokenOfKind($closeParenthesis, ['{']); + assert(is_int($openBrace)); + $closeBrace = $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $openBrace); + + $variables = []; + $properties = []; + $propertyToVariableMap = []; + + for ($index = $openBrace + 1; $index < $closeBrace; $index++) { + if (!$this->tokens[$index]->isGivenKind(T_VARIABLE)) { + continue; + } + + $semicolonIndex = $this->tokens->getNextMeaningfulToken($index); + assert(is_int($semicolonIndex)); + if (!$this->tokens[$semicolonIndex]->equals(';')) { + continue; + } + + $propertyIndex = $this->getPropertyIndex($index, $openBrace); + if (null === $propertyIndex) { + continue; + } + + $properties[$propertyIndex] = $this->tokens[$propertyIndex]->getContent(); + $variables[$index] = $this->tokens[$index]->getContent(); + $propertyToVariableMap[$propertyIndex] = $index; + } + + foreach ($this->getDuplicatesIndices($properties) as $duplicate) { + unset($variables[$propertyToVariableMap[$duplicate]]); + } + + foreach ($this->getDuplicatesIndices($variables) as $duplicate) { + unset($variables[$duplicate]); + } + + return array_flip($variables); + } + + public function getConstructorPromotableParameters(): array + { + $openParenthesis = $this->tokens->getNextTokenOfKind($this->constructorIndex, ['(']); + assert(is_int($openParenthesis)); + $closeParenthesis = $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesis); + + $constructorPromotableParameters = []; + for ($index = $openParenthesis + 1; $index < $closeParenthesis; $index++) { + if (!$this->tokens[$index]->isGivenKind(T_VARIABLE)) { + continue; + } + + $typeIndex = $this->tokens->getPrevMeaningfulToken($index); + assert(is_int($typeIndex)); + if ($this->tokens[$typeIndex]->equalsAny(['(', ',', [T_CALLABLE], [T_ELLIPSIS]])) { + continue; + } + + $visibilityIndex = $this->tokens->getPrevTokenOfKind( + $index, + [ + '(', + ',', + [CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PRIVATE], + [CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PROTECTED], + [CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PUBLIC], + ], + ); + assert(is_int($visibilityIndex)); + if (!$this->tokens[$visibilityIndex]->equalsAny(['(', ','])) { + continue; + } + + $constructorPromotableParameters[$index] = $this->tokens[$index]->getContent(); + } + + return $constructorPromotableParameters; + } + + private function getDuplicatesIndices( + array $array, + ): array { + $duplicates = []; + $values = []; + foreach ($array as $key => $value) { + if (array_key_exists($value, $values)) { + $duplicates[$values[$value]] = $values[$value]; + $duplicates[$key] = $key; + } + $values[$value] = $key; + } + + return $duplicates; + } + + private function getPropertyIndex( + int $index, + int $openBrace, + ): ?int { + $assignmentIndex = $this->tokens->getPrevMeaningfulToken($index); + assert(is_int($assignmentIndex)); + if (!$this->tokens[$assignmentIndex]->equals('=')) { + return null; + } + + $propertyIndex = $this->tokens->getPrevMeaningfulToken($assignmentIndex); + if (!$this->tokens[$propertyIndex]->isGivenKind(T_STRING)) { + return null; + } + assert(is_int($propertyIndex)); + + $objectOperatorIndex = $this->tokens->getPrevMeaningfulToken($propertyIndex); + assert(is_int($objectOperatorIndex)); + + $thisIndex = $this->tokens->getPrevMeaningfulToken($objectOperatorIndex); + assert(is_int($thisIndex)); + if (!$this->tokens[$thisIndex]->equals([T_VARIABLE, '$this'])) { + return null; + } + + $prevThisIndex = $this->tokens->getPrevMeaningfulToken($thisIndex); + assert(is_int($prevThisIndex)); + if ($prevThisIndex > $openBrace && !$this->tokens[$prevThisIndex]->equalsAny(['}', ';'])) { + return null; + } + + return $propertyIndex; + } +} diff --git a/PhpCsFixer/Analyzer/Element/SwitchElement.php b/PhpCsFixer/Analyzer/Element/SwitchElement.php new file mode 100644 index 0000000..dc4a885 --- /dev/null +++ b/PhpCsFixer/Analyzer/Element/SwitchElement.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Vairogs\PhpCsFixerCustomFixers\PhpCsFixer\Analyzer\Element; + +/** + * @internal + */ +final readonly class SwitchElement +{ + public function __construct( + private int $casesStart, + private int $casesEnd, + private array $cases, + ) { + } + + public function getCases(): array + { + return $this->cases; + } + + public function getCasesEnd(): int + { + return $this->casesEnd; + } + + public function getCasesStart(): int + { + return $this->casesStart; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f9dd21a --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "vairogs/php-cs-fixer-custom-fixers", + "type": "library", + "license": "BSD-3-Clause", + "autoload": { + "psr-4": { + "Vairogs\\PhpCsFixerCustomFixers\\": "" + } + }, + "minimum-stability": "dev", + "prefer-stable": false, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-master": "0.1.x-dev" + } + }, + "require": { + "php": ">=8.3", + "friendsofphp/php-cs-fixer": "*", + "symfony/finder": "*", + "vairogs/functions": "*" + }, + "suggest": { + "doctrine/migrations": "*" + }, + "conflict": { + "friendsofphp/php-cs-fixer": "<3.57.0" + } +}