Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\Doctrine\Annotation\Tokens as DoctrineAnnotationTokens;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\FCT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixer\Tokenizer\TokensAnalyzer;
/**
* @internal
*
* @phpstan-type _AutogeneratedInputConfiguration array{
* ignored_tags?: list<string>,
* }
* @phpstan-type _AutogeneratedComputedConfiguration array{
* ignored_tags: list<string>,
* }
*
* @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
*/
abstract class AbstractDoctrineAnnotationFixer extends AbstractFixer implements ConfigurableFixerInterface
{
private const CLASS_MODIFIERS = [\T_ABSTRACT, \T_FINAL, FCT::T_READONLY];
private const MODIFIER_KINDS = [\T_PUBLIC, \T_PROTECTED, \T_PRIVATE, \T_FINAL, \T_ABSTRACT, \T_NS_SEPARATOR, \T_STRING, CT::T_NULLABLE_TYPE, FCT::T_READONLY, FCT::T_PRIVATE_SET, FCT::T_PROTECTED_SET, FCT::T_PUBLIC_SET];
/**
* @var array<int, array{classIndex: int, token: Token, type: string}>
*/
private array $classyElements;
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(\T_DOC_COMMENT);
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
// fetch indices one time, this is safe as we never add or remove a token during fixing
$analyzer = new TokensAnalyzer($tokens);
$this->classyElements = $analyzer->getClassyElements();
foreach ($tokens->findGivenKind(\T_DOC_COMMENT) as $index => $docCommentToken) {
if (!$this->nextElementAcceptsDoctrineAnnotations($tokens, $index)) {
continue;
}
$doctrineAnnotationTokens = DoctrineAnnotationTokens::createFromDocComment(
$docCommentToken,
$this->configuration['ignored_tags'] // @phpstan-ignore-line
);
$this->fixAnnotations($doctrineAnnotationTokens);
$tokens[$index] = new Token([\T_DOC_COMMENT, $doctrineAnnotationTokens->getCode()]);
}
}
/**
* Fixes Doctrine annotations from the given PHPDoc style comment.
*/
abstract protected function fixAnnotations(DoctrineAnnotationTokens $doctrineAnnotationTokens): void;
protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
{
return new FixerConfigurationResolver([
(new FixerOptionBuilder('ignored_tags', 'List of tags that must not be treated as Doctrine Annotations.'))
->setAllowedTypes(['string[]'])
->setDefault([
// PHPDocumentor 1
'abstract',
'access',
'code',
'deprec',
'encode',
'exception',
'final',
'ingroup',
'inheritdoc',
'inheritDoc',
'magic',
'name',
'toc',
'tutorial',
'private',
'static',
'staticvar',
'staticVar',
'throw',
// PHPDocumentor 2
'api',
'author',
'category',
'copyright',
'deprecated',
'example',
'filesource',
'global',
'ignore',
'internal',
'license',
'link',
'method',
'package',
'param',
'property',
'property-read',
'property-write',
'return',
'see',
'since',
'source',
'subpackage',
'throws',
'todo',
'TODO',
'usedBy',
'uses',
'var',
'version',
// PHPUnit
'after',
'afterClass',
'backupGlobals',
'backupStaticAttributes',
'before',
'beforeClass',
'codeCoverageIgnore',
'codeCoverageIgnoreStart',
'codeCoverageIgnoreEnd',
'covers',
'coversDefaultClass',
'coversNothing',
'dataProvider',
'depends',
'expectedException',
'expectedExceptionCode',
'expectedExceptionMessage',
'expectedExceptionMessageRegExp',
'group',
'large',
'medium',
'preserveGlobalState',
'requires',
'runTestsInSeparateProcesses',
'runInSeparateProcess',
'small',
'test',
'testdox',
'ticket',
'uses',
// PHPCheckStyle
'SuppressWarnings',
// PHPStorm
'noinspection',
// PEAR
'package_version',
// PlantUML
'enduml',
'startuml',
// Psalm
'psalm',
// PHPStan
'phpstan',
'template',
// other
'fix',
'FIXME',
'fixme',
'override',
])
->getOption(),
]);
}
private function nextElementAcceptsDoctrineAnnotations(Tokens $tokens, int $index): bool
{
do {
$index = $tokens->getNextMeaningfulToken($index);
if (null === $index) {
return false;
}
} while ($tokens[$index]->isGivenKind(self::CLASS_MODIFIERS));
if ($tokens[$index]->isGivenKind(\T_CLASS)) {
return true;
}
while ($tokens[$index]->isGivenKind(self::MODIFIER_KINDS)) {
$index = $tokens->getNextMeaningfulToken($index);
}
if (!isset($this->classyElements[$index])) {
return false;
}
return $tokens[$this->classyElements[$index]['classIndex']]->isGivenKind(\T_CLASS); // interface, enums and traits cannot have doctrine annotations
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\ConfigurationException\RequiredFixerConfigurationException;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
abstract class AbstractFixer implements FixerInterface
{
protected WhitespacesFixerConfig $whitespacesConfig;
public function __construct()
{
if ($this instanceof ConfigurableFixerInterface) {
try {
$this->configure([]);
} catch (RequiredFixerConfigurationException $e) {
// ignore
}
}
if ($this instanceof WhitespacesAwareFixerInterface) {
$this->whitespacesConfig = $this->getDefaultWhitespacesFixerConfig();
}
}
final public function fix(\SplFileInfo $file, Tokens $tokens): void
{
if ($this instanceof ConfigurableFixerInterface && property_exists($this, 'configuration') && null === $this->configuration) {
throw new RequiredFixerConfigurationException($this->getName(), 'Configuration is required.');
}
if (0 < $tokens->count() && $this->isCandidate($tokens) && $this->supports($file)) {
$this->applyFix($file, $tokens);
}
}
public function isRisky(): bool
{
return false;
}
public function getName(): string
{
$nameParts = explode('\\', static::class);
$name = substr(end($nameParts), 0, -\strlen('Fixer'));
return Utils::camelCaseToUnderscore($name);
}
public function getPriority(): int
{
return 0;
}
public function supports(\SplFileInfo $file): bool
{
return true;
}
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;
}
abstract protected function applyFix(\SplFileInfo $file, Tokens $tokens): void;
private function getDefaultWhitespacesFixerConfig(): WhitespacesFixerConfig
{
static $defaultWhitespacesFixerConfig = null;
if (null === $defaultWhitespacesFixerConfig) {
$defaultWhitespacesFixerConfig = new WhitespacesFixerConfig(' ', "\n");
}
return $defaultWhitespacesFixerConfig;
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\Tokenizer\Analyzer\ArgumentsAnalyzer;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @internal
*/
abstract class AbstractFopenFlagFixer extends AbstractFunctionReferenceFixer
{
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isAllTokenKindsFound([\T_STRING, \T_CONSTANT_ENCAPSED_STRING]);
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$argumentsAnalyzer = new ArgumentsAnalyzer();
$index = 0;
$end = $tokens->count() - 1;
while (true) {
$candidate = $this->find('fopen', $tokens, $index, $end);
if (null === $candidate) {
break;
}
$index = $candidate[1]; // proceed to '(' of `fopen`
// fetch arguments
$arguments = $argumentsAnalyzer->getArguments(
$tokens,
$index,
$candidate[2]
);
$argumentsCount = \count($arguments); // argument count sanity check
if ($argumentsCount < 2 || $argumentsCount > 4) {
continue;
}
$argumentStartIndex = array_keys($arguments)[1]; // get second argument index
$this->fixFopenFlagToken(
$tokens,
$argumentStartIndex,
$arguments[$argumentStartIndex]
);
}
}
abstract protected function fixFopenFlagToken(Tokens $tokens, int $argumentStartIndex, int $argumentEndIndex): void;
protected function isValidModeString(string $mode): bool
{
$modeLength = \strlen($mode);
if ($modeLength < 1 || $modeLength > 13) { // 13 === length 'r+w+a+x+c+etb'
return false;
}
$validFlags = [
'a' => true,
'b' => true,
'c' => true,
'e' => true,
'r' => true,
't' => true,
'w' => true,
'x' => true,
];
if (!isset($validFlags[$mode[0]])) {
return false;
}
unset($validFlags[$mode[0]]);
for ($i = 1; $i < $modeLength; ++$i) {
if (isset($validFlags[$mode[$i]])) {
unset($validFlags[$mode[$i]]);
continue;
}
if ('+' !== $mode[$i]
|| (
'a' !== $mode[$i - 1] // 'a+','c+','r+','w+','x+'
&& 'c' !== $mode[$i - 1]
&& 'r' !== $mode[$i - 1]
&& 'w' !== $mode[$i - 1]
&& 'x' !== $mode[$i - 1]
)
) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @internal
*
* @author Vladimir Reznichenko <kalessil@gmail.com>
*/
abstract class AbstractFunctionReferenceFixer extends AbstractFixer
{
private ?FunctionsAnalyzer $functionsAnalyzer = null;
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(\T_STRING);
}
public function isRisky(): bool
{
return true;
}
/**
* Looks up Tokens sequence for suitable candidates and delivers boundaries information,
* which can be supplied by other methods in this abstract class.
*
* @return ?array{int, int, int} returns $functionName, $openParenthesis, $closeParenthesis packed into array
*/
protected function find(string $functionNameToSearch, Tokens $tokens, int $start = 0, ?int $end = null): ?array
{
if (null === $this->functionsAnalyzer) {
$this->functionsAnalyzer = new FunctionsAnalyzer();
}
// make interface consistent with findSequence
$end ??= $tokens->count();
// find raw sequence which we can analyse for context
$candidateSequence = [[\T_STRING, $functionNameToSearch], '('];
$matches = $tokens->findSequence($candidateSequence, $start, $end, false);
if (null === $matches) {
return null; // not found, simply return without further attempts
}
// translate results for humans
[$functionName, $openParenthesis] = array_keys($matches);
if (!$this->functionsAnalyzer->isGlobalFunctionCall($tokens, $functionName)) {
return $this->find($functionNameToSearch, $tokens, $openParenthesis, $end);
}
return [$functionName, $openParenthesis, $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesis)];
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\Tokenizer\Tokens;
abstract class AbstractNoUselessElseFixer extends AbstractFixer
{
public function getPriority(): int
{
// should be run before NoWhitespaceInBlankLineFixer, NoExtraBlankLinesFixer, BracesFixer and after NoEmptyStatementFixer.
return 39;
}
protected function isSuperfluousElse(Tokens $tokens, int $index): bool
{
$previousBlockStart = $index;
do {
// Check if all 'if', 'else if ' and 'elseif' blocks above this 'else' always end,
// if so this 'else' is overcomplete.
[$previousBlockStart, $previousBlockEnd] = $this->getPreviousBlock($tokens, $previousBlockStart);
// short 'if' detection
$previous = $previousBlockEnd;
if ($tokens[$previous]->equals('}')) {
$previous = $tokens->getPrevMeaningfulToken($previous);
}
if (
!$tokens[$previous]->equals(';') // 'if' block doesn't end with semicolon, keep 'else'
|| $tokens[$tokens->getPrevMeaningfulToken($previous)]->equals('{') // empty 'if' block, keep 'else'
) {
return false;
}
$candidateIndex = $tokens->getPrevTokenOfKind(
$previous,
[
';',
[\T_BREAK],
[\T_CLOSE_TAG],
[\T_CONTINUE],
[\T_EXIT],
[\T_GOTO],
[\T_IF],
[\T_RETURN],
[\T_THROW],
]
);
if (null === $candidateIndex || $tokens[$candidateIndex]->equalsAny([';', [\T_CLOSE_TAG], [\T_IF]])) {
return false;
}
if ($tokens[$candidateIndex]->isGivenKind(\T_THROW)) {
$previousIndex = $tokens->getPrevMeaningfulToken($candidateIndex);
if (!$tokens[$previousIndex]->equalsAny([';', '{'])) {
return false;
}
}
if ($this->isInConditional($tokens, $candidateIndex, $previousBlockStart)
|| $this->isInConditionWithoutBraces($tokens, $candidateIndex, $previousBlockStart)
) {
return false;
}
// implicit continue, i.e. delete candidate
} while (!$tokens[$previousBlockStart]->isGivenKind(\T_IF));
return true;
}
/**
* Return the first and last token index of the previous block.
*
* [0] First is either T_IF, T_ELSE or T_ELSEIF
* [1] Last is either '}' or ';' / T_CLOSE_TAG for short notation blocks
*
* @param int $index T_IF, T_ELSE, T_ELSEIF
*
* @return array{int, int}
*/
private function getPreviousBlock(Tokens $tokens, int $index): array
{
$close = $previous = $tokens->getPrevMeaningfulToken($index);
// short 'if' detection
if ($tokens[$close]->equals('}')) {
$previous = $tokens->findBlockStart(Tokens::BLOCK_TYPE_CURLY_BRACE, $close);
}
$open = $tokens->getPrevTokenOfKind($previous, [[\T_IF], [\T_ELSE], [\T_ELSEIF]]);
if ($tokens[$open]->isGivenKind(\T_IF)) {
$elseCandidate = $tokens->getPrevMeaningfulToken($open);
if ($tokens[$elseCandidate]->isGivenKind(\T_ELSE)) {
$open = $elseCandidate;
}
}
return [$open, $close];
}
/**
* @param int $index Index of the token to check
* @param int $lowerLimitIndex Lower limit index. Since the token to check will always be in a conditional we must stop checking at this index
*/
private function isInConditional(Tokens $tokens, int $index, int $lowerLimitIndex): bool
{
$candidateIndex = $tokens->getPrevTokenOfKind($index, [')', ';', ':']);
if ($tokens[$candidateIndex]->equals(':')) {
return true;
}
if (!$tokens[$candidateIndex]->equals(')')) {
return false; // token is ';' or close tag
}
// token is always ')' here.
// If it is part of the condition the token is always in, return false.
// If it is not it is a nested condition so return true
$open = $tokens->findBlockStart(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $candidateIndex);
return $tokens->getPrevMeaningfulToken($open) > $lowerLimitIndex;
}
/**
* For internal use only, as it is not perfect.
*
* Returns if the token at given index is part of an if/elseif/else statement
* without {}. Assumes not passing the last `;`/close tag of the statement, not
* out of range index, etc.
*
* @param int $index Index of the token to check
*/
private function isInConditionWithoutBraces(Tokens $tokens, int $index, int $lowerLimitIndex): bool
{
do {
if ($tokens[$index]->isComment() || $tokens[$index]->isWhitespace()) {
$index = $tokens->getPrevMeaningfulToken($index);
}
$token = $tokens[$index];
if ($token->isGivenKind([\T_IF, \T_ELSEIF, \T_ELSE])) {
return true;
}
if ($token->equals(';')) {
return false;
}
if ($token->equals('{')) {
$index = $tokens->getPrevMeaningfulToken($index);
// OK if belongs to: for, do, while, foreach
// Not OK if belongs to: if, else, elseif
if ($tokens[$index]->isGivenKind(\T_DO)) {
--$index;
continue;
}
if (!$tokens[$index]->equals(')')) {
return false; // like `else {`
}
$index = $tokens->findBlockStart(
Tokens::BLOCK_TYPE_PARENTHESIS_BRACE,
$index
);
$index = $tokens->getPrevMeaningfulToken($index);
if ($tokens[$index]->isGivenKind([\T_IF, \T_ELSEIF])) {
return false;
}
} elseif ($token->equals(')')) {
$type = Tokens::detectBlockType($token);
$index = $tokens->findBlockStart(
$type['type'],
$index
);
$index = $tokens->getPrevMeaningfulToken($index);
} else {
--$index;
}
} while ($index > $lowerLimitIndex);
return false;
}
}

View File

@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\DocBlock\Annotation;
use PhpCsFixer\DocBlock\DocBlock;
use PhpCsFixer\DocBlock\TypeExpression;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
use PhpCsFixer\Tokenizer\Analyzer\NamespacesAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\NamespaceUsesAnalyzer;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @internal
*
* @phpstan-type _CommonTypeInfo array{commonType: string, isNullable: bool}
* @phpstan-type _AutogeneratedInputConfiguration array{
* scalar_types?: bool,
* types_map?: array<string, string>,
* union_types?: bool,
* }
* @phpstan-type _AutogeneratedComputedConfiguration array{
* scalar_types: bool,
* types_map: array<string, string>,
* union_types: bool,
* }
*
* @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
*/
abstract class AbstractPhpdocToTypeDeclarationFixer extends AbstractFixer implements ConfigurableFixerInterface
{
/** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
use ConfigurableFixerTrait;
private const REGEX_CLASS = '(?:\\\?+'.TypeExpression::REGEX_IDENTIFIER
.'(\\\\'.TypeExpression::REGEX_IDENTIFIER.')*+)';
/**
* @var array<string, int>
*/
private array $versionSpecificTypes = [
'void' => 7_01_00,
'iterable' => 7_01_00,
'object' => 7_02_00,
'mixed' => 8_00_00,
'never' => 8_01_00,
];
/**
* @var array<string, bool>
*/
private array $scalarTypes = [
'bool' => true,
'float' => true,
'int' => true,
'string' => true,
];
/**
* @var array<string, bool>
*/
private static array $syntaxValidationCache = [];
public function isRisky(): bool
{
return true;
}
abstract protected function isSkippedType(string $type): bool;
protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
{
return new FixerConfigurationResolver([
(new FixerOptionBuilder('scalar_types', 'Fix also scalar types; may have unexpected behaviour due to PHP bad type coercion system.'))
->setAllowedTypes(['bool'])
->setDefault(true)
->getOption(),
(new FixerOptionBuilder('union_types', 'Fix also union types; turned on by default on PHP >= 8.0.0.'))
->setAllowedTypes(['bool'])
->setDefault(\PHP_VERSION_ID >= 8_00_00)
->getOption(),
(new FixerOptionBuilder('types_map', 'Map of custom types, e.g. template types from PHPStan.'))
->setAllowedTypes(['array<string, string>'])
->setDefault([])
->getOption(),
]);
}
/**
* @param int $index The index of the function token
*/
protected function findFunctionDocComment(Tokens $tokens, int $index): ?int
{
do {
$index = $tokens->getPrevNonWhitespace($index);
} while ($tokens[$index]->isGivenKind([
\T_COMMENT,
\T_ABSTRACT,
\T_FINAL,
\T_PRIVATE,
\T_PROTECTED,
\T_PUBLIC,
\T_STATIC,
]));
if ($tokens[$index]->isGivenKind(\T_DOC_COMMENT)) {
return $index;
}
return null;
}
/**
* @return list<Annotation>
*/
protected function getAnnotationsFromDocComment(string $name, Tokens $tokens, int $docCommentIndex): array
{
$namespacesAnalyzer = new NamespacesAnalyzer();
$namespace = $namespacesAnalyzer->getNamespaceAt($tokens, $docCommentIndex);
$namespaceUsesAnalyzer = new NamespaceUsesAnalyzer();
$namespaceUses = $namespaceUsesAnalyzer->getDeclarationsInNamespace($tokens, $namespace);
$doc = new DocBlock(
$tokens[$docCommentIndex]->getContent(),
$namespace,
$namespaceUses
);
return $doc->getAnnotationsOfType($name);
}
/**
* @return list<Token>
*/
protected function createTypeDeclarationTokens(string $type, bool $isNullable): array
{
$newTokens = [];
if (true === $isNullable && 'mixed' !== $type) {
$newTokens[] = new Token([CT::T_NULLABLE_TYPE, '?']);
}
$newTokens = array_merge(
$newTokens,
$this->createTokensFromRawType($type)->toArray()
);
// 'scalar's, 'void', 'iterable' and 'object' must be unqualified
foreach ($newTokens as $i => $token) {
if ($token->isGivenKind(\T_STRING)) {
$typeUnqualified = $token->getContent();
if (
(isset($this->scalarTypes[$typeUnqualified]) || isset($this->versionSpecificTypes[$typeUnqualified]))
&& isset($newTokens[$i - 1])
&& '\\' === $newTokens[$i - 1]->getContent()
) {
unset($newTokens[$i - 1]);
}
}
}
return array_values($newTokens);
}
/**
* Each fixer inheriting from this class must define a way of creating token collection representing type
* gathered from phpDoc, e.g. `Foo|Bar` should be transformed into 3 tokens (`Foo`, `|` and `Bar`).
* This can't be standardised, because some types may be allowed in one place, and invalid in others.
*
* @param string $type Type determined (and simplified) from phpDoc
*/
abstract protected function createTokensFromRawType(string $type): Tokens;
/**
* @return ?_CommonTypeInfo
*/
protected function getCommonTypeInfo(TypeExpression $typesExpression, bool $isReturnType): ?array
{
$commonType = $typesExpression->getCommonType();
$isNullable = $typesExpression->allowsNull();
if (null === $commonType) {
return null;
}
if ($isNullable && 'void' === $commonType) {
return null;
}
if ('static' === $commonType && (!$isReturnType || \PHP_VERSION_ID < 8_00_00)) {
$commonType = 'self';
}
if ($this->isSkippedType($commonType)) {
return null;
}
if (isset($this->versionSpecificTypes[$commonType]) && \PHP_VERSION_ID < $this->versionSpecificTypes[$commonType]) {
return null;
}
if (\array_key_exists($commonType, $this->configuration['types_map'])) {
$commonType = $this->configuration['types_map'][$commonType];
}
if (isset($this->scalarTypes[$commonType])) {
if (false === $this->configuration['scalar_types']) {
return null;
}
} elseif (!Preg::match('/^'.self::REGEX_CLASS.'$/', $commonType)) {
return null;
}
return ['commonType' => $commonType, 'isNullable' => $isNullable];
}
protected function getUnionTypes(TypeExpression $typesExpression, bool $isReturnType): ?string
{
if (\PHP_VERSION_ID < 8_00_00) {
return null;
}
if (!$typesExpression->isUnionType()) {
return null;
}
if (false === $this->configuration['union_types']) {
return null;
}
$types = $typesExpression->getTypes();
$isNullable = $typesExpression->allowsNull();
$unionTypes = [];
$containsOtherThanIterableType = false;
$containsOtherThanEmptyType = false;
foreach ($types as $type) {
if ('null' === $type) {
continue;
}
if ($this->isSkippedType($type)) {
return null;
}
if (isset($this->versionSpecificTypes[$type]) && \PHP_VERSION_ID < $this->versionSpecificTypes[$type]) {
return null;
}
$typeExpression = new TypeExpression($type, null, []);
$commonTypeInfo = $this->getCommonTypeInfo($typeExpression, $isReturnType);
if (null === $commonTypeInfo) {
return null;
}
$commonType = $commonTypeInfo['commonType'];
if (!$containsOtherThanIterableType && !\in_array($commonType, ['array', \Traversable::class, 'iterable'], true)) {
$containsOtherThanIterableType = true;
}
if ($isReturnType && !$containsOtherThanEmptyType && !\in_array($commonType, ['null', 'void', 'never'], true)) {
$containsOtherThanEmptyType = true;
}
if (!$isNullable && $commonTypeInfo['isNullable']) {
$isNullable = true;
}
$unionTypes[] = $commonType;
}
if (!$containsOtherThanIterableType) {
return null;
}
if ($isReturnType && !$containsOtherThanEmptyType) {
return null;
}
if ($isNullable) {
$unionTypes[] = 'null';
}
return implode($typesExpression->getTypesGlue(), array_unique($unionTypes));
}
final protected function isValidSyntax(string $code): bool
{
if (!isset(self::$syntaxValidationCache[$code])) {
try {
Tokens::fromCode($code);
self::$syntaxValidationCache[$code] = true;
} catch (\ParseError $e) {
self::$syntaxValidationCache[$code] = false;
}
}
return self::$syntaxValidationCache[$code];
}
/**
* @return list<string>
*/
final protected static function getTypesToExclude(string $content): array
{
$typesToExclude = [];
$docBlock = new DocBlock($content);
foreach ($docBlock->getAnnotationsOfType(['phpstan-type', 'psalm-type']) as $annotation) {
$typesToExclude[] = $annotation->getTypeExpression()->toString();
}
foreach ($docBlock->getAnnotationsOfType(['phpstan-import-type', 'psalm-import-type']) as $annotation) {
$content = trim($annotation->getContent());
if (Preg::match('/\bas\s+('.TypeExpression::REGEX_IDENTIFIER.')$/', $content, $matches)) {
$typesToExclude[] = $matches[1];
continue;
}
$typesToExclude[] = $annotation->getTypeExpression()->toString();
}
return $typesToExclude;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\DocBlock\Annotation;
use PhpCsFixer\DocBlock\DocBlock;
use PhpCsFixer\DocBlock\TypeExpression;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* This abstract fixer provides a base for fixers to fix types in PHPDoc.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
*
* @internal
*/
abstract class AbstractPhpdocTypesFixer extends AbstractFixer
{
/**
* The annotation tags search inside.
*
* @var list<string>
*/
protected array $tags;
public function __construct()
{
parent::__construct();
$this->tags = Annotation::getTagsWithTypes();
}
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(\T_DOC_COMMENT);
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
foreach ($tokens as $index => $token) {
if (!$token->isGivenKind(\T_DOC_COMMENT)) {
continue;
}
$doc = new DocBlock($token->getContent());
$annotations = $doc->getAnnotationsOfType($this->tags);
if (0 === \count($annotations)) {
continue;
}
foreach ($annotations as $annotation) {
$this->fixType($annotation);
}
$tokens[$index] = new Token([\T_DOC_COMMENT, $doc->getContent()]);
}
}
/**
* Actually normalize the given type.
*/
abstract protected function normalize(string $type): string;
/**
* Fix the type at the given line.
*
* We must be super careful not to modify parts of words.
*
* This will be nicely handled behind the scenes for us by the annotation class.
*/
private function fixType(Annotation $annotation): void
{
$typeExpression = $annotation->getTypeExpression();
if (null === $typeExpression) {
return;
}
$newTypeExpression = $typeExpression->mapTypes(function (TypeExpression $type) {
if (!$type->isCompositeType()) {
$value = $this->normalize($type->toString());
return new TypeExpression($value, null, []);
}
return $type;
});
$annotation->setTypes([$newTypeExpression->toString()]);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
abstract class AbstractProxyFixer extends AbstractFixer
{
/**
* @var array<string, FixerInterface>
*/
protected array $proxyFixers = [];
public function __construct()
{
foreach (Utils::sortFixers($this->createProxyFixers()) as $proxyFixer) {
$this->proxyFixers[$proxyFixer->getName()] = $proxyFixer;
}
parent::__construct();
}
public function isCandidate(Tokens $tokens): bool
{
foreach ($this->proxyFixers as $fixer) {
if ($fixer->isCandidate($tokens)) {
return true;
}
}
return false;
}
public function isRisky(): bool
{
foreach ($this->proxyFixers as $fixer) {
if ($fixer->isRisky()) {
return true;
}
}
return false;
}
public function getPriority(): int
{
if (\count($this->proxyFixers) > 1) {
throw new \LogicException('You need to override this method to provide the priority of combined fixers.');
}
return reset($this->proxyFixers)->getPriority();
}
public function supports(\SplFileInfo $file): bool
{
foreach ($this->proxyFixers as $fixer) {
if ($fixer->supports($file)) {
return true;
}
}
return false;
}
public function setWhitespacesConfig(WhitespacesFixerConfig $config): void
{
parent::setWhitespacesConfig($config);
foreach ($this->proxyFixers as $fixer) {
if ($fixer instanceof WhitespacesAwareFixerInterface) {
$fixer->setWhitespacesConfig($config);
}
}
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
foreach ($this->proxyFixers as $fixer) {
$fixer->fix($file, $tokens);
}
}
/**
* @return list<FixerInterface>
*/
abstract protected function createProxyFixers(): array;
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
use PhpCsFixer\Utils;
/**
* @author Andreas Möller <am@localheinz.com>
*
* @internal
*/
final class Cache implements CacheInterface
{
private SignatureInterface $signature;
/**
* @var array<string, string>
*/
private array $hashes = [];
public function __construct(SignatureInterface $signature)
{
$this->signature = $signature;
}
public function getSignature(): SignatureInterface
{
return $this->signature;
}
public function has(string $file): bool
{
return \array_key_exists($file, $this->hashes);
}
public function get(string $file): ?string
{
if (!$this->has($file)) {
return null;
}
return $this->hashes[$file];
}
public function set(string $file, string $hash): void
{
$this->hashes[$file] = $hash;
}
public function clear(string $file): void
{
unset($this->hashes[$file]);
}
public function toJson(): string
{
$json = json_encode([
'php' => $this->getSignature()->getPhpVersion(),
'version' => $this->getSignature()->getFixerVersion(),
'indent' => $this->getSignature()->getIndent(),
'lineEnding' => $this->getSignature()->getLineEnding(),
'rules' => $this->getSignature()->getRules(),
'hashes' => $this->hashes,
]);
if (\JSON_ERROR_NONE !== json_last_error() || false === $json) {
throw new \UnexpectedValueException(\sprintf(
'Cannot encode cache signature to JSON, error: "%s". If you have non-UTF8 chars in your signature, like in license for `header_comment`, consider enabling `ext-mbstring` or install `symfony/polyfill-mbstring`.',
json_last_error_msg()
));
}
return $json;
}
/**
* @throws \InvalidArgumentException
*/
public static function fromJson(string $json): self
{
$data = json_decode($json, true);
if (null === $data && \JSON_ERROR_NONE !== json_last_error()) {
throw new \InvalidArgumentException(\sprintf(
'Value needs to be a valid JSON string, got "%s", error: "%s".',
$json,
json_last_error_msg()
));
}
$requiredKeys = [
'php',
'version',
'indent',
'lineEnding',
'rules',
'hashes',
];
$missingKeys = array_diff_key(array_flip($requiredKeys), $data);
if (\count($missingKeys) > 0) {
throw new \InvalidArgumentException(\sprintf(
'JSON data is missing keys %s',
Utils::naturalLanguageJoin(array_keys($missingKeys))
));
}
$signature = new Signature(
$data['php'],
$data['version'],
$data['indent'],
$data['lineEnding'],
$data['rules']
);
$cache = new self($signature);
// before v3.11.1 the hashes were crc32 encoded and saved as integers
// @TODO: remove the to string cast/array_map in v4.0
$cache->hashes = array_map(static fn ($v): string => \is_int($v) ? (string) $v : $v, $data['hashes']);
return $cache;
}
/**
* @internal
*/
public function backfillHashes(self $oldCache): bool
{
if (!$this->getSignature()->equals($oldCache->getSignature())) {
return false;
}
$this->hashes = array_merge($oldCache->hashes, $this->hashes);
return true;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
/**
* @author Andreas Möller <am@localheinz.com>
*
* @internal
*/
interface CacheInterface
{
public function getSignature(): SignatureInterface;
public function has(string $file): bool;
public function get(string $file): ?string;
public function set(string $file, string $hash): void;
public function clear(string $file): void;
public function toJson(): string;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
interface CacheManagerInterface
{
public function needFixing(string $file, string $fileContent): bool;
public function setFile(string $file, string $fileContent): void;
public function setFileHash(string $file, string $hash): void;
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @readonly
*
* @internal
*/
final class Directory implements DirectoryInterface
{
private string $directoryName;
public function __construct(string $directoryName)
{
$this->directoryName = $directoryName;
}
public function getRelativePathTo(string $file): string
{
$file = $this->normalizePath($file);
if (
'' === $this->directoryName
|| 0 !== stripos($file, $this->directoryName.\DIRECTORY_SEPARATOR)
) {
return $file;
}
return substr($file, \strlen($this->directoryName) + 1);
}
private function normalizePath(string $path): string
{
return str_replace(['\\', '/'], \DIRECTORY_SEPARATOR, $path);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
interface DirectoryInterface
{
public function getRelativePathTo(string $file): string;
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
use PhpCsFixer\Hasher;
/**
* Class supports caching information about state of fixing files.
*
* Cache is supported only for phar version and version installed via composer.
*
* File will be processed by PHP CS Fixer only if any of the following conditions is fulfilled:
* - cache is corrupt
* - fixer version changed
* - rules changed
* - file is new
* - file changed
*
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class FileCacheManager implements CacheManagerInterface
{
public const WRITE_FREQUENCY = 10;
private FileHandlerInterface $handler;
private SignatureInterface $signature;
private bool $isDryRun;
private DirectoryInterface $cacheDirectory;
private int $writeCounter = 0;
private bool $signatureWasUpdated = false;
private CacheInterface $cache;
public function __construct(
FileHandlerInterface $handler,
SignatureInterface $signature,
bool $isDryRun = false,
?DirectoryInterface $cacheDirectory = null
) {
$this->handler = $handler;
$this->signature = $signature;
$this->isDryRun = $isDryRun;
$this->cacheDirectory = $cacheDirectory ?? new Directory('');
$this->readCache();
}
public function __destruct()
{
if (true === $this->signatureWasUpdated || 0 !== $this->writeCounter) {
$this->writeCache();
}
}
/**
* This class is not intended to be serialized,
* and cannot be deserialized (see __wakeup method).
*/
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
/**
* Disable the deserialization of the class to prevent attacker executing
* code by leveraging the __destruct method.
*
* @see https://owasp.org/www-community/vulnerabilities/PHP_Object_Injection
*/
public function __wakeup(): void
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function needFixing(string $file, string $fileContent): bool
{
$file = $this->cacheDirectory->getRelativePathTo($file);
return !$this->cache->has($file) || $this->cache->get($file) !== $this->calcHash($fileContent);
}
public function setFile(string $file, string $fileContent): void
{
$this->setFileHash($file, $this->calcHash($fileContent));
}
public function setFileHash(string $file, string $hash): void
{
$file = $this->cacheDirectory->getRelativePathTo($file);
if ($this->isDryRun && $this->cache->has($file) && $this->cache->get($file) !== $hash) {
$this->cache->clear($file);
} else {
$this->cache->set($file, $hash);
}
if (self::WRITE_FREQUENCY === ++$this->writeCounter) {
$this->writeCounter = 0;
$this->writeCache();
}
}
private function readCache(): void
{
$cache = $this->handler->read();
if (null === $cache || !$this->signature->equals($cache->getSignature())) {
$cache = new Cache($this->signature);
$this->signatureWasUpdated = true;
}
$this->cache = $cache;
}
private function writeCache(): void
{
$this->handler->write($this->cache);
}
private function calcHash(string $content): string
{
return Hasher::calculate($content);
}
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
use Symfony\Component\Filesystem\Exception\IOException;
/**
* @author Andreas Möller <am@localheinz.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class FileHandler implements FileHandlerInterface
{
private \SplFileInfo $fileInfo;
private int $fileMTime = 0;
public function __construct(string $file)
{
$this->fileInfo = new \SplFileInfo($file);
}
public function getFile(): string
{
return $this->fileInfo->getPathname();
}
public function read(): ?CacheInterface
{
if (!$this->fileInfo->isFile() || !$this->fileInfo->isReadable()) {
return null;
}
$fileObject = $this->fileInfo->openFile('r');
$cache = $this->readFromHandle($fileObject);
$this->fileMTime = $this->getFileCurrentMTime();
unset($fileObject); // explicitly close file handler
return $cache;
}
public function write(CacheInterface $cache): void
{
$this->ensureFileIsWriteable();
$fileObject = $this->fileInfo->openFile('r+');
if (method_exists($cache, 'backfillHashes') && $this->fileMTime < $this->getFileCurrentMTime()) {
$resultOfFlock = $fileObject->flock(\LOCK_EX);
if (false === $resultOfFlock) {
// Lock failed, OK - we continue without the lock.
// noop
}
$oldCache = $this->readFromHandle($fileObject);
$fileObject->rewind();
if (null !== $oldCache) {
$cache->backfillHashes($oldCache);
}
}
$resultOfTruncate = $fileObject->ftruncate(0);
if (false === $resultOfTruncate) {
// Truncate failed. OK - we do not save the cache.
return;
}
$resultOfWrite = $fileObject->fwrite($cache->toJson());
if (false === $resultOfWrite) {
// Write failed. OK - we did not save the cache.
return;
}
$resultOfFlush = $fileObject->fflush();
if (false === $resultOfFlush) {
// Flush failed. OK - part of cache can be missing, in case this was last chunk in this pid.
// noop
}
$this->fileMTime = time(); // we could take the fresh `mtime` of file that we just modified with `$this->getFileCurrentMTime()`, but `time()` should be good enough here and reduce IO operation
}
private function getFileCurrentMTime(): int
{
clearstatcache(true, $this->fileInfo->getPathname());
$mtime = $this->fileInfo->getMTime();
if (false === $mtime) {
// cannot check mtime? OK - let's pretend file is old.
$mtime = 0;
}
return $mtime;
}
private function readFromHandle(\SplFileObject $fileObject): ?CacheInterface
{
try {
$size = $fileObject->getSize();
if (false === $size || 0 === $size) {
return null;
}
$content = $fileObject->fread($size);
if (false === $content) {
return null;
}
return Cache::fromJson($content);
} catch (\InvalidArgumentException $exception) {
return null;
}
}
private function ensureFileIsWriteable(): void
{
if ($this->fileInfo->isFile() && $this->fileInfo->isWritable()) {
// all good
return;
}
if ($this->fileInfo->isDir()) {
throw new IOException(
\sprintf('Cannot write cache file "%s" as the location exists as directory.', $this->fileInfo->getRealPath()),
0,
null,
$this->fileInfo->getPathname()
);
}
if ($this->fileInfo->isFile() && !$this->fileInfo->isWritable()) {
throw new IOException(
\sprintf('Cannot write to file "%s" as it is not writable.', $this->fileInfo->getRealPath()),
0,
null,
$this->fileInfo->getPathname()
);
}
$this->createFile($this->fileInfo->getPathname());
}
private function createFile(string $file): void
{
$dir = \dirname($file);
// Ensure path is created, but ignore if already exists. FYI: ignore EA suggestion in IDE,
// `mkdir()` returns `false` for existing paths, so we can't mix it with `is_dir()` in one condition.
if (!@is_dir($dir)) {
@mkdir($dir, 0777, true);
}
if (!@is_dir($dir)) {
throw new IOException(
\sprintf('Directory of cache file "%s" does not exists and couldn\'t be created.', $file),
0,
null,
$file
);
}
@touch($file);
@chmod($file, 0666);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
/**
* @author Andreas Möller <am@localheinz.com>
*
* @internal
*/
interface FileHandlerInterface
{
public function getFile(): string;
public function read(): ?CacheInterface;
public function write(CacheInterface $cache): void;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
/**
* @author Andreas Möller <am@localheinz.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class NullCacheManager implements CacheManagerInterface
{
public function needFixing(string $file, string $fileContent): bool
{
return true;
}
public function setFile(string $file, string $fileContent): void {}
public function setFileHash(string $file, string $hash): void {}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
/**
* @author Andreas Möller <am@localheinz.com>
*
* @readonly
*
* @internal
*/
final class Signature implements SignatureInterface
{
private string $phpVersion;
private string $fixerVersion;
private string $indent;
private string $lineEnding;
/**
* @var array<string, array<string, mixed>|bool>
*/
private array $rules;
/**
* @param array<string, array<string, mixed>|bool> $rules
*/
public function __construct(string $phpVersion, string $fixerVersion, string $indent, string $lineEnding, array $rules)
{
$this->phpVersion = $phpVersion;
$this->fixerVersion = $fixerVersion;
$this->indent = $indent;
$this->lineEnding = $lineEnding;
$this->rules = self::makeJsonEncodable($rules);
}
public function getPhpVersion(): string
{
return $this->phpVersion;
}
public function getFixerVersion(): string
{
return $this->fixerVersion;
}
public function getIndent(): string
{
return $this->indent;
}
public function getLineEnding(): string
{
return $this->lineEnding;
}
public function getRules(): array
{
return $this->rules;
}
public function equals(SignatureInterface $signature): bool
{
return $this->phpVersion === $signature->getPhpVersion()
&& $this->fixerVersion === $signature->getFixerVersion()
&& $this->indent === $signature->getIndent()
&& $this->lineEnding === $signature->getLineEnding()
&& $this->rules === $signature->getRules();
}
/**
* @param array<string, array<string, mixed>|bool> $data
*
* @return array<string, array<string, mixed>|bool>
*/
private static function makeJsonEncodable(array $data): array
{
array_walk_recursive($data, static function (&$item): void {
if (\is_string($item) && false === mb_detect_encoding($item, 'utf-8', true)) {
$item = base64_encode($item);
}
});
return $data;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Cache;
/**
* @author Andreas Möller <am@localheinz.com>
*
* @internal
*/
interface SignatureInterface
{
public function getPhpVersion(): string;
public function getFixerVersion(): string;
public function getIndent(): string;
public function getLineEnding(): string;
/**
* @return array<string, array<string, mixed>|bool>
*/
public function getRules(): array;
public function equals(self $signature): bool;
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\Runner\Parallel\ParallelConfig;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Katsuhiro Ogawa <ko.fivestar@gmail.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
class Config implements ConfigInterface, ParallelAwareConfigInterface, UnsupportedPhpVersionAllowedConfigInterface
{
/**
* @var non-empty-string
*/
private string $cacheFile = '.php-cs-fixer.cache';
/**
* @var list<FixerInterface>
*/
private array $customFixers = [];
/**
* @var null|iterable<\SplFileInfo>
*/
private ?iterable $finder = null;
private string $format;
private bool $hideProgress = false;
/**
* @var non-empty-string
*/
private string $indent = ' ';
private bool $isRiskyAllowed = false;
/**
* @var non-empty-string
*/
private string $lineEnding = "\n";
private string $name;
private ParallelConfig $parallelConfig;
private ?string $phpExecutable = null;
/**
* @TODO: 4.0 - update to @PER
*
* @var array<string, array<string, mixed>|bool>
*/
private array $rules;
private bool $usingCache = true;
private bool $isUnsupportedPhpVersionAllowed = false;
public function __construct(string $name = 'default')
{
// @TODO 4.0 cleanup
if (Utils::isFutureModeEnabled()) {
$this->name = $name.' (future mode)';
$this->rules = ['@PER-CS' => true];
$this->format = '@auto';
} else {
$this->name = $name;
$this->rules = ['@PSR12' => true];
$this->format = 'txt';
}
// @TODO 4.0 cleanup
if (Utils::isFutureModeEnabled() || filter_var(getenv('PHP_CS_FIXER_PARALLEL'), \FILTER_VALIDATE_BOOL)) {
$this->parallelConfig = ParallelConfigFactory::detect();
} else {
$this->parallelConfig = ParallelConfigFactory::sequential();
}
// @TODO 4.0 cleanup
if (false !== getenv('PHP_CS_FIXER_IGNORE_ENV')) {
$this->isUnsupportedPhpVersionAllowed = filter_var(getenv('PHP_CS_FIXER_IGNORE_ENV'), \FILTER_VALIDATE_BOOL);
}
}
/**
* @return non-empty-string
*/
public function getCacheFile(): string
{
return $this->cacheFile;
}
public function getCustomFixers(): array
{
return $this->customFixers;
}
/**
* @return Finder
*/
public function getFinder(): iterable
{
$this->finder ??= new Finder();
return $this->finder;
}
public function getFormat(): string
{
return $this->format;
}
public function getHideProgress(): bool
{
return $this->hideProgress;
}
public function getIndent(): string
{
return $this->indent;
}
public function getLineEnding(): string
{
return $this->lineEnding;
}
public function getName(): string
{
return $this->name;
}
public function getParallelConfig(): ParallelConfig
{
return $this->parallelConfig;
}
public function getPhpExecutable(): ?string
{
return $this->phpExecutable;
}
public function getRiskyAllowed(): bool
{
return $this->isRiskyAllowed;
}
public function getRules(): array
{
return $this->rules;
}
public function getUsingCache(): bool
{
return $this->usingCache;
}
public function getUnsupportedPhpVersionAllowed(): bool
{
return $this->isUnsupportedPhpVersionAllowed;
}
public function registerCustomFixers(iterable $fixers): ConfigInterface
{
foreach ($fixers as $fixer) {
$this->addCustomFixer($fixer);
}
return $this;
}
/**
* @param non-empty-string $cacheFile
*/
public function setCacheFile(string $cacheFile): ConfigInterface
{
$this->cacheFile = $cacheFile;
return $this;
}
public function setFinder(iterable $finder): ConfigInterface
{
$this->finder = $finder;
return $this;
}
public function setFormat(string $format): ConfigInterface
{
$this->format = $format;
return $this;
}
public function setHideProgress(bool $hideProgress): ConfigInterface
{
$this->hideProgress = $hideProgress;
return $this;
}
/**
* @param non-empty-string $indent
*/
public function setIndent(string $indent): ConfigInterface
{
$this->indent = $indent;
return $this;
}
/**
* @param non-empty-string $lineEnding
*/
public function setLineEnding(string $lineEnding): ConfigInterface
{
$this->lineEnding = $lineEnding;
return $this;
}
public function setParallelConfig(ParallelConfig $config): ConfigInterface
{
$this->parallelConfig = $config;
return $this;
}
public function setPhpExecutable(?string $phpExecutable): ConfigInterface
{
$this->phpExecutable = $phpExecutable;
return $this;
}
public function setRiskyAllowed(bool $isRiskyAllowed): ConfigInterface
{
$this->isRiskyAllowed = $isRiskyAllowed;
return $this;
}
public function setRules(array $rules): ConfigInterface
{
$this->rules = $rules;
return $this;
}
public function setUsingCache(bool $usingCache): ConfigInterface
{
$this->usingCache = $usingCache;
return $this;
}
public function setUnsupportedPhpVersionAllowed(bool $isUnsupportedPhpVersionAllowed): ConfigInterface
{
$this->isUnsupportedPhpVersionAllowed = $isUnsupportedPhpVersionAllowed;
return $this;
}
private function addCustomFixer(FixerInterface $fixer): void
{
$this->customFixers[] = $fixer;
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use PhpCsFixer\Fixer\FixerInterface;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
interface ConfigInterface
{
/** @internal */
public const PHP_VERSION_SYNTAX_SUPPORTED = '8.4';
/**
* Returns the path to the cache file.
*
* @return null|non-empty-string Returns null if not using cache
*/
public function getCacheFile(): ?string;
/**
* Returns the custom fixers to use.
*
* @return list<FixerInterface>
*/
public function getCustomFixers(): array;
/**
* Returns files to scan.
*
* @return iterable<\SplFileInfo>
*/
public function getFinder(): iterable;
public function getFormat(): string;
/**
* Returns true if progress should be hidden.
*/
public function getHideProgress(): bool;
/**
* @return non-empty-string
*/
public function getIndent(): string;
/**
* @return non-empty-string
*/
public function getLineEnding(): string;
/**
* Returns the name of the configuration.
*
* The name must be all lowercase and without any spaces.
*
* @return string The name of the configuration
*/
public function getName(): string;
/**
* Get configured PHP executable, if any.
*
* @deprecated
*
* @TODO 4.0 remove me
*/
public function getPhpExecutable(): ?string;
/**
* Check if it is allowed to run risky fixers.
*/
public function getRiskyAllowed(): bool;
/**
* Get rules.
*
* Keys of array are names of fixers/sets, values are true/false.
*
* @return array<string, array<string, mixed>|bool>
*/
public function getRules(): array;
/**
* Returns true if caching should be enabled.
*/
public function getUsingCache(): bool;
/**
* Adds a suite of custom fixers.
*
* Name of custom fixer should follow `VendorName/rule_name` convention.
*
* @param iterable<FixerInterface> $fixers
*/
public function registerCustomFixers(iterable $fixers): self;
/**
* Sets the path to the cache file.
*
* @param non-empty-string $cacheFile
*/
public function setCacheFile(string $cacheFile): self;
/**
* @param iterable<\SplFileInfo> $finder
*/
public function setFinder(iterable $finder): self;
public function setFormat(string $format): self;
public function setHideProgress(bool $hideProgress): self;
/**
* @param non-empty-string $indent
*/
public function setIndent(string $indent): self;
/**
* @param non-empty-string $lineEnding
*/
public function setLineEnding(string $lineEnding): self;
/**
* Set PHP executable.
*
* @deprecated
*
* @TODO 4.0 remove me
*/
public function setPhpExecutable(?string $phpExecutable): self;
/**
* Set if it is allowed to run risky fixers.
*/
public function setRiskyAllowed(bool $isRiskyAllowed): self;
/**
* Set rules.
*
* Keys of array are names of fixers or sets.
* Value for set must be bool (turn it on or off).
* Value for fixer may be bool (turn it on or off) or array of configuration
* (turn it on and contains configuration for FixerInterface::configure method).
*
* @param array<string, array<string, mixed>|bool> $rules
*/
public function setRules(array $rules): self;
public function setUsingCache(bool $usingCache): self;
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\ConfigurationException;
use PhpCsFixer\Console\Command\FixCommandExitStatusCalculator;
/**
* Exceptions of this type are thrown on misconfiguration of the Fixer.
*
* @internal
*
* @final Only internal extending this class is supported
*/
class InvalidConfigurationException extends \InvalidArgumentException
{
public function __construct(string $message, ?int $code = null, ?\Throwable $previous = null)
{
parent::__construct(
$message,
$code ?? FixCommandExitStatusCalculator::EXIT_STATUS_FLAG_HAS_INVALID_CONFIG,
$previous
);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\ConfigurationException;
use PhpCsFixer\Console\Command\FixCommandExitStatusCalculator;
/**
* Exception thrown by Fixers on misconfiguration.
*
* @internal
*
* @final Only internal extending this class is supported
*/
class InvalidFixerConfigurationException extends InvalidConfigurationException
{
private string $fixerName;
public function __construct(string $fixerName, string $message, ?\Throwable $previous = null)
{
parent::__construct(
\sprintf('[%s] %s', $fixerName, $message),
FixCommandExitStatusCalculator::EXIT_STATUS_FLAG_HAS_INVALID_FIXER_CONFIG,
$previous
);
$this->fixerName = $fixerName;
}
public function getFixerName(): string
{
return $this->fixerName;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\ConfigurationException;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class InvalidForEnvFixerConfigurationException extends InvalidFixerConfigurationException {}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\ConfigurationException;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class RequiredFixerConfigurationException extends InvalidFixerConfigurationException {}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console;
use PhpCsFixer\Console\Command\CheckCommand;
use PhpCsFixer\Console\Command\DescribeCommand;
use PhpCsFixer\Console\Command\FixCommand;
use PhpCsFixer\Console\Command\HelpCommand;
use PhpCsFixer\Console\Command\ListFilesCommand;
use PhpCsFixer\Console\Command\ListSetsCommand;
use PhpCsFixer\Console\Command\SelfUpdateCommand;
use PhpCsFixer\Console\Command\WorkerCommand;
use PhpCsFixer\Console\SelfUpdate\GithubClient;
use PhpCsFixer\Console\SelfUpdate\NewVersionChecker;
use PhpCsFixer\PharChecker;
use PhpCsFixer\Runner\Parallel\WorkerException;
use PhpCsFixer\ToolInfo;
use PhpCsFixer\Utils;
use Symfony\Component\Console\Application as BaseApplication;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\CompleteCommand;
use Symfony\Component\Console\Command\DumpCompletionCommand;
use Symfony\Component\Console\Command\ListCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class Application extends BaseApplication
{
public const NAME = 'PHP CS Fixer';
public const VERSION = '3.86.0';
public const VERSION_CODENAME = 'Alexander';
/**
* @readonly
*/
private ToolInfo $toolInfo;
private ?Command $executedCommand = null;
public function __construct()
{
parent::__construct(self::NAME, self::VERSION);
$this->toolInfo = new ToolInfo();
// in alphabetical order
$this->add(new DescribeCommand());
$this->add(new CheckCommand($this->toolInfo));
$this->add(new FixCommand($this->toolInfo));
$this->add(new ListFilesCommand($this->toolInfo));
$this->add(new ListSetsCommand());
$this->add(new SelfUpdateCommand(
new NewVersionChecker(new GithubClient()),
$this->toolInfo,
new PharChecker()
));
$this->add(new WorkerCommand($this->toolInfo));
}
// polyfill for `add` method, as it is not available in Symfony 8.0
public function add(Command $command): ?Command
{
if (method_exists($this, 'addCommand')) { // @phpstan-ignore function.impossibleType
return $this->addCommand($command);
}
return parent::add($command);
}
public static function getMajorVersion(): int
{
return (int) explode('.', self::VERSION)[0];
}
public function doRun(InputInterface $input, OutputInterface $output): int
{
$stdErr = $output instanceof ConsoleOutputInterface
? $output->getErrorOutput()
: ($input->hasParameterOption('--format', true) && 'txt' !== $input->getParameterOption('--format', null, true) ? null : $output);
if (null !== $stdErr) {
$warningsDetector = new WarningsDetector($this->toolInfo);
$warningsDetector->detectOldVendor();
$warningsDetector->detectOldMajor();
$warningsDetector->detectNonMonolithic();
$warnings = $warningsDetector->getWarnings();
if (\count($warnings) > 0) {
foreach ($warnings as $warning) {
$stdErr->writeln(\sprintf($stdErr->isDecorated() ? '<bg=yellow;fg=black;>%s</>' : '%s', $warning));
}
$stdErr->writeln('');
}
}
$result = parent::doRun($input, $output);
if (
null !== $stdErr
&& $output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE
) {
$triggeredDeprecations = Utils::getTriggeredDeprecations();
if (\count($triggeredDeprecations) > 0) {
$stdErr->writeln('');
$stdErr->writeln($stdErr->isDecorated() ? '<bg=yellow;fg=black;>Detected deprecations in use (they will stop working in next major release):</>' : 'Detected deprecations in use (they will stop working in next major release):');
foreach ($triggeredDeprecations as $deprecation) {
$stdErr->writeln(\sprintf('- %s', $deprecation));
}
}
}
return $result;
}
/**
* @internal
*/
public static function getAbout(bool $decorated = false): string
{
$longVersion = \sprintf('%s <info>%s</info>', self::NAME, self::VERSION);
$commit = '@git-commit@';
$versionCommit = '';
if ('@'.'git-commit@' !== $commit) { /** @phpstan-ignore-line as `$commit` is replaced during phar building */
$versionCommit = substr($commit, 0, 7);
}
$about = implode('', [
$longVersion,
$versionCommit ? \sprintf(' <info>(%s)</info>', $versionCommit) : '', // @phpstan-ignore-line to avoid `Ternary operator condition is always true|false.`
self::VERSION_CODENAME ? \sprintf(' <info>%s</info>', self::VERSION_CODENAME) : '', // @phpstan-ignore-line to avoid `Ternary operator condition is always true|false.`
' by <comment>Fabien Potencier</comment>, <comment>Dariusz Ruminski</comment> and <comment>contributors</comment>.',
]);
if (false === $decorated) {
return strip_tags($about);
}
return $about;
}
/**
* @internal
*/
public static function getAboutWithRuntime(bool $decorated = false): string
{
$about = self::getAbout(true)."\nPHP runtime: <info>".\PHP_VERSION.'</info>';
if (false === $decorated) {
return strip_tags($about);
}
return $about;
}
public function getLongVersion(): string
{
return self::getAboutWithRuntime(true);
}
protected function getDefaultCommands(): array
{
return [new HelpCommand(), new ListCommand(), new CompleteCommand(), new DumpCompletionCommand()];
}
/**
* @throws \Throwable
*/
protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int
{
$this->executedCommand = $command;
return parent::doRunCommand($command, $input, $output);
}
protected function doRenderThrowable(\Throwable $e, OutputInterface $output): void
{
// Since parallel analysis utilises child processes, and they have their own output,
// we need to capture the output of the child process to determine it there was an exception.
// Default render format is not machine-friendly, so we need to override it for `worker` command,
// in order to be able to easily parse exception data for further displaying on main process' side.
if ($this->executedCommand instanceof WorkerCommand) {
$output->writeln(WorkerCommand::ERROR_PREFIX.json_encode(
[
'class' => \get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'code' => $e->getCode(),
'trace' => $e->getTraceAsString(),
]
));
return;
}
parent::doRenderThrowable($e, $output);
if ($output->isVeryVerbose() && $e instanceof WorkerException) {
$output->writeln('<comment>Original trace from worker:</comment>');
$output->writeln('');
$output->writeln($e->getOriginalTraceAsString());
$output->writeln('');
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use PhpCsFixer\ToolInfoInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
/**
* @author Greg Korba <greg@codito.dev>
*
* @internal
*/
#[AsCommand(name: 'check', description: 'Checks if configured files/directories comply with configured rules.')]
final class CheckCommand extends FixCommand
{
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'check'; // @phpstan-ignore property.parentPropertyFinalByPhpDoc
/** @TODO PHP 8.0 - remove the property */
protected static $defaultDescription = 'Checks if configured files/directories comply with configured rules.'; // @phpstan-ignore property.parentPropertyFinalByPhpDoc
public function __construct(ToolInfoInterface $toolInfo)
{
parent::__construct($toolInfo);
}
public function getHelp(): string
{
$help = explode('<comment>--dry-run</comment>', parent::getHelp());
return substr($help[0], 0, strrpos($help[0], "\n") - 1)
.substr($help[1], strpos($help[1], "\n"));
}
protected function configure(): void
{
parent::configure();
$this->setDefinition([
...array_values($this->getDefinition()->getArguments()),
...array_values(array_filter(
$this->getDefinition()->getOptions(),
static fn (InputOption $option): bool => 'dry-run' !== $option->getName()
)),
]);
}
protected function isDryRun(InputInterface $input): bool
{
return true;
}
}

View File

@@ -0,0 +1,493 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use PhpCsFixer\Config;
use PhpCsFixer\Console\Application;
use PhpCsFixer\Console\ConfigurationResolver;
use PhpCsFixer\Differ\DiffConsoleFormatter;
use PhpCsFixer\Differ\FullDiffer;
use PhpCsFixer\Documentation\FixerDocumentGenerator;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\DeprecatedFixerInterface;
use PhpCsFixer\Fixer\ExperimentalFixerInterface;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\Fixer\InternalFixerInterface;
use PhpCsFixer\FixerConfiguration\AliasedFixerOption;
use PhpCsFixer\FixerConfiguration\AllowedValueSubset;
use PhpCsFixer\FixerConfiguration\DeprecatedFixerOption;
use PhpCsFixer\FixerDefinition\CodeSampleInterface;
use PhpCsFixer\FixerDefinition\FileSpecificCodeSampleInterface;
use PhpCsFixer\FixerDefinition\VersionSpecificCodeSampleInterface;
use PhpCsFixer\FixerFactory;
use PhpCsFixer\Preg;
use PhpCsFixer\RuleSet\DeprecatedRuleSetDescriptionInterface;
use PhpCsFixer\RuleSet\RuleSets;
use PhpCsFixer\StdinFileInfo;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixer\ToolInfo;
use PhpCsFixer\Utils;
use PhpCsFixer\WordMatcher;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
#[AsCommand(name: 'describe', description: 'Describe rule / ruleset.')]
final class DescribeCommand extends Command
{
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'describe';
/** @TODO PHP 8.0 - remove the property */
protected static $defaultDescription = 'Describe rule / ruleset.';
/**
* @var ?list<string>
*/
private ?array $setNames = null;
private FixerFactory $fixerFactory;
/**
* @var null|array<string, FixerInterface>
*/
private ?array $fixers = null;
public function __construct(?FixerFactory $fixerFactory = null)
{
parent::__construct();
if (null === $fixerFactory) {
$fixerFactory = new FixerFactory();
$fixerFactory->registerBuiltInFixers();
}
$this->fixerFactory = $fixerFactory;
}
protected function configure(): void
{
$this->setDefinition(
[
new InputArgument('name', InputArgument::REQUIRED, 'Name of rule / set.', null, fn () => array_merge($this->getSetNames(), array_keys($this->getFixers()))),
new InputOption('config', '', InputOption::VALUE_REQUIRED, 'The path to a .php-cs-fixer.php file.'),
]
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($output instanceof ConsoleOutputInterface) {
$stdErr = $output->getErrorOutput();
$stdErr->writeln(Application::getAboutWithRuntime(true));
}
$resolver = new ConfigurationResolver(
new Config(),
['config' => $input->getOption('config')],
getcwd(),
new ToolInfo()
);
$this->fixerFactory->registerCustomFixers($resolver->getConfig()->getCustomFixers());
$name = $input->getArgument('name');
try {
if (str_starts_with($name, '@')) {
$this->describeSet($output, $name);
return 0;
}
$this->describeRule($output, $name);
} catch (DescribeNameNotFoundException $e) {
$matcher = new WordMatcher(
'set' === $e->getType() ? $this->getSetNames() : array_keys($this->getFixers())
);
$alternative = $matcher->match($name);
$this->describeList($output, $e->getType());
throw new \InvalidArgumentException(\sprintf(
'%s "%s" not found.%s',
ucfirst($e->getType()),
$name,
null === $alternative ? '' : ' Did you mean "'.$alternative.'"?'
));
}
return 0;
}
private function describeRule(OutputInterface $output, string $name): void
{
$fixers = $this->getFixers();
if (!isset($fixers[$name])) {
throw new DescribeNameNotFoundException($name, 'rule');
}
$fixer = $fixers[$name];
$definition = $fixer->getDefinition();
$output->writeln(\sprintf('<fg=blue>Description of the <info>`%s`</info> rule.</>', $name));
$output->writeln('');
if ($output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
$output->writeln(\sprintf('Fixer class: <comment>%s</comment>.', \get_class($fixer)));
$output->writeln('');
}
if ($fixer instanceof DeprecatedFixerInterface) {
$successors = $fixer->getSuccessorsNames();
$message = [] === $successors
? \sprintf('it will be removed in version %d.0', Application::getMajorVersion() + 1)
: \sprintf('use %s instead', Utils::naturalLanguageJoinWithBackticks($successors));
$endMessage = '. '.ucfirst($message);
Utils::triggerDeprecation(new \RuntimeException(str_replace('`', '"', "Rule \"{$name}\" is deprecated{$endMessage}.")));
$message = Preg::replace('/(`[^`]+`)/', '<info>$1</info>', $message);
$output->writeln(\sprintf('<error>DEPRECATED</error>: %s.', $message));
$output->writeln('');
}
$output->writeln($definition->getSummary());
$description = $definition->getDescription();
if (null !== $description) {
$output->writeln($description);
}
$output->writeln('');
if ($fixer instanceof ExperimentalFixerInterface) {
$output->writeln('<error>Fixer applying this rule is EXPERIMENTAL.</error>.');
$output->writeln('It is not covered with backward compatibility promise and may produce unstable or unexpected results.');
$output->writeln('');
}
if ($fixer instanceof InternalFixerInterface) {
$output->writeln('<error>Fixer applying this rule is INTERNAL.</error>.');
$output->writeln('It is expected to be used only on PHP CS Fixer project itself.');
$output->writeln('');
}
if ($fixer->isRisky()) {
$output->writeln('<error>Fixer applying this rule is RISKY.</error>');
$riskyDescription = $definition->getRiskyDescription();
if (null !== $riskyDescription) {
$output->writeln($riskyDescription);
}
$output->writeln('');
}
if ($fixer instanceof ConfigurableFixerInterface) {
$configurationDefinition = $fixer->getConfigurationDefinition();
$options = $configurationDefinition->getOptions();
$output->writeln(\sprintf('Fixer is configurable using following option%s:', 1 === \count($options) ? '' : 's'));
foreach ($options as $option) {
$line = '* <info>'.OutputFormatter::escape($option->getName()).'</info>';
$allowed = HelpCommand::getDisplayableAllowedValues($option);
if (null === $allowed) {
$allowed = array_map(
static fn (string $type): string => '<comment>'.$type.'</comment>',
$option->getAllowedTypes(),
);
} else {
$allowed = array_map(static fn ($value): string => $value instanceof AllowedValueSubset
? 'a subset of <comment>'.Utils::toString($value->getAllowedValues()).'</comment>'
: '<comment>'.Utils::toString($value).'</comment>', $allowed);
}
$line .= ' ('.Utils::naturalLanguageJoin($allowed, '').')';
$description = Preg::replace('/(`.+?`)/', '<info>$1</info>', OutputFormatter::escape($option->getDescription()));
$line .= ': '.lcfirst(Preg::replace('/\.$/', '', $description)).'; ';
if ($option->hasDefault()) {
$line .= \sprintf(
'defaults to <comment>%s</comment>',
Utils::toString($option->getDefault())
);
} else {
$line .= '<comment>required</comment>';
}
if ($option instanceof DeprecatedFixerOption) {
$line .= '. <error>DEPRECATED</error>: '.Preg::replace(
'/(`.+?`)/',
'<info>$1</info>',
OutputFormatter::escape(lcfirst($option->getDeprecationMessage()))
);
}
if ($option instanceof AliasedFixerOption) {
$line .= '; <error>DEPRECATED</error> alias: <comment>'.$option->getAlias().'</comment>';
}
$output->writeln($line);
}
$output->writeln('');
}
$codeSamples = array_filter($definition->getCodeSamples(), static function (CodeSampleInterface $codeSample): bool {
if ($codeSample instanceof VersionSpecificCodeSampleInterface) {
return $codeSample->isSuitableFor(\PHP_VERSION_ID);
}
return true;
});
if (0 === \count($definition->getCodeSamples())) {
$output->writeln([
'Fixing examples are not available for this rule.',
'',
]);
} elseif (0 === \count($codeSamples)) {
$output->writeln([
'Fixing examples <error>cannot be</error> demonstrated on the current PHP version.',
'',
]);
} else {
$output->writeln('Fixing examples:');
$differ = new FullDiffer();
$diffFormatter = new DiffConsoleFormatter(
$output->isDecorated(),
\sprintf(
'<comment> ---------- begin diff ----------</comment>%s%%s%s<comment> ----------- end diff -----------</comment>',
\PHP_EOL,
\PHP_EOL
)
);
foreach ($codeSamples as $index => $codeSample) {
$old = $codeSample->getCode();
$tokens = Tokens::fromCode($old);
$configuration = $codeSample->getConfiguration();
if ($fixer instanceof ConfigurableFixerInterface) {
$fixer->configure($configuration ?? []);
}
$file = $codeSample instanceof FileSpecificCodeSampleInterface
? $codeSample->getSplFileInfo()
: new StdinFileInfo();
$fixer->fix($file, $tokens);
$diff = $differ->diff($old, $tokens->generateCode());
if ($fixer instanceof ConfigurableFixerInterface) {
if (null === $configuration) {
$output->writeln(\sprintf(' * Example #%d. Fixing with the <comment>default</comment> configuration.', $index + 1));
} else {
$output->writeln(\sprintf(' * Example #%d. Fixing with configuration: <comment>%s</comment>.', $index + 1, Utils::toString($codeSample->getConfiguration())));
}
} else {
$output->writeln(\sprintf(' * Example #%d.', $index + 1));
}
$output->writeln([$diffFormatter->format($diff, ' %s'), '']);
}
}
$ruleSetConfigs = FixerDocumentGenerator::getSetsOfRule($name);
if ([] !== $ruleSetConfigs) {
ksort($ruleSetConfigs);
$plural = 1 !== \count($ruleSetConfigs) ? 's' : '';
$output->writeln("Fixer is part of the following rule set{$plural}:");
$ruleSetDefinitions = RuleSets::getSetDefinitions();
foreach ($ruleSetConfigs as $set => $config) {
\assert(isset($ruleSetDefinitions[$set]));
$ruleSetDescription = $ruleSetDefinitions[$set];
$deprecatedDesc = ($ruleSetDescription instanceof DeprecatedRuleSetDescriptionInterface) ? ' *(deprecated)*' : '';
if (null !== $config) {
$output->writeln(\sprintf('* <info>%s</info> with config: <comment>%s</comment>', $set.$deprecatedDesc, Utils::toString($config)));
} else {
$output->writeln(\sprintf('* <info>%s</info> with <comment>default</comment> config', $set.$deprecatedDesc));
}
}
$output->writeln('');
}
}
private function describeSet(OutputInterface $output, string $name): void
{
if (!\in_array($name, $this->getSetNames(), true)) {
throw new DescribeNameNotFoundException($name, 'set');
}
$ruleSetDefinitions = RuleSets::getSetDefinitions();
$ruleSetDescription = $ruleSetDefinitions[$name];
$fixers = $this->getFixers();
$output->writeln(\sprintf('<fg=blue>Description of the <info>`%s`</info> set.</>', $ruleSetDescription->getName()));
$output->writeln('');
$output->writeln($this->replaceRstLinks($ruleSetDescription->getDescription()));
$output->writeln('');
if ($ruleSetDescription instanceof DeprecatedRuleSetDescriptionInterface) {
$successors = $ruleSetDescription->getSuccessorsNames();
$message = [] === $successors
? \sprintf('it will be removed in version %d.0', Application::getMajorVersion() + 1)
: \sprintf('use %s instead', Utils::naturalLanguageJoinWithBackticks($successors));
Utils::triggerDeprecation(new \RuntimeException(str_replace('`', '"', "Set \"{$name}\" is deprecated, {$message}.")));
$message = Preg::replace('/(`[^`]+`)/', '<info>$1</info>', $message);
$output->writeln(\sprintf('<error>DEPRECATED</error>: %s.', $message));
$output->writeln('');
}
if ($ruleSetDescription->isRisky()) {
$output->writeln('<error>This set contains risky rules.</error>');
$output->writeln('');
}
$help = '';
foreach ($ruleSetDescription->getRules() as $rule => $config) {
if (str_starts_with($rule, '@')) {
$set = $ruleSetDefinitions[$rule];
$help .= \sprintf(
" * <info>%s</info>%s\n | %s\n\n",
$rule,
$set->isRisky() ? ' <error>risky</error>' : '',
$this->replaceRstLinks($set->getDescription())
);
continue;
}
$fixer = $fixers[$rule];
$definition = $fixer->getDefinition();
$help .= \sprintf(
" * <info>%s</info>%s\n | %s\n%s\n",
$rule,
$fixer->isRisky() ? ' <error>risky</error>' : '',
$definition->getSummary(),
true !== $config ? \sprintf(" <comment>| Configuration: %s</comment>\n", Utils::toString($config)) : ''
);
}
$output->write($help);
}
/**
* @return array<string, FixerInterface>
*/
private function getFixers(): array
{
if (null !== $this->fixers) {
return $this->fixers;
}
$fixers = [];
foreach ($this->fixerFactory->getFixers() as $fixer) {
$fixers[$fixer->getName()] = $fixer;
}
$this->fixers = $fixers;
ksort($this->fixers);
return $this->fixers;
}
/**
* @return list<string>
*/
private function getSetNames(): array
{
if (null !== $this->setNames) {
return $this->setNames;
}
$this->setNames = RuleSets::getSetDefinitionNames();
return $this->setNames;
}
/**
* @param string $type 'rule'|'set'
*/
private function describeList(OutputInterface $output, string $type): void
{
if ($output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) {
return;
}
if ($output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE || 'set' === $type) {
$output->writeln('<comment>Defined sets:</comment>');
$items = $this->getSetNames();
foreach ($items as $item) {
$output->writeln(\sprintf('* <info>%s</info>', $item));
}
}
if ($output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE || 'rule' === $type) {
$output->writeln('<comment>Defined rules:</comment>');
$items = array_keys($this->getFixers());
foreach ($items as $item) {
$output->writeln(\sprintf('* <info>%s</info>', $item));
}
}
}
private function replaceRstLinks(string $content): string
{
return Preg::replaceCallback(
'/(`[^<]+<[^>]+>`_)/',
static fn (array $matches) => Preg::replaceCallback(
'/`(.*)<(.*)>`_/',
static fn (array $matches): string => $matches[1].'('.$matches[2].')',
$matches[1]
),
$content
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
/**
* @internal
*/
final class DescribeNameNotFoundException extends \InvalidArgumentException
{
private string $name;
/**
* 'rule'|'set'.
*/
private string $type;
public function __construct(string $name, string $type)
{
$this->name = $name;
$this->type = $type;
parent::__construct();
}
public function getName(): string
{
return $this->name;
}
public function getType(): string
{
return $this->type;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use PhpCsFixer\Documentation\DocumentationLocator;
use PhpCsFixer\Documentation\FixerDocumentGenerator;
use PhpCsFixer\Documentation\RuleSetDocumentationGenerator;
use PhpCsFixer\FixerFactory;
use PhpCsFixer\RuleSet\RuleSets;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
/**
* @internal
*/
#[AsCommand(name: 'documentation', description: 'Dumps the documentation of the project into its "/doc" directory.')]
final class DocumentationCommand extends Command
{
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'documentation';
/** @TODO PHP 8.0 - remove the property */
protected static $defaultDescription = 'Dumps the documentation of the project into its "/doc" directory.';
private Filesystem $filesystem;
public function __construct(Filesystem $filesystem)
{
parent::__construct();
$this->filesystem = $filesystem;
}
protected function configure(): void
{
$this->setAliases(['doc']);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$locator = new DocumentationLocator();
$fixerFactory = new FixerFactory();
$fixerFactory->registerBuiltInFixers();
$fixers = $fixerFactory->getFixers();
$setDefinitions = RuleSets::getSetDefinitions();
$fixerDocumentGenerator = new FixerDocumentGenerator($locator);
$ruleSetDocumentationGenerator = new RuleSetDocumentationGenerator($locator);
// Array of existing fixer docs.
// We first override existing files, and then we will delete files that are no longer needed.
// We cannot remove all files first, as generation of docs is re-using existing docs to extract code-samples for
// VersionSpecificCodeSample under incompatible PHP version.
$docForFixerRelativePaths = [];
foreach ($fixers as $fixer) {
$docForFixerRelativePaths[] = $locator->getFixerDocumentationFileRelativePath($fixer);
$this->filesystem->dumpFile(
$locator->getFixerDocumentationFilePath($fixer),
$fixerDocumentGenerator->generateFixerDocumentation($fixer)
);
}
foreach (
(new Finder())->files()
->in($locator->getFixersDocumentationDirectoryPath())
->notPath($docForFixerRelativePaths) as $file
) {
$this->filesystem->remove($file->getPathname());
}
// Fixer doc. index
$this->filesystem->dumpFile(
$locator->getFixersDocumentationIndexFilePath(),
$fixerDocumentGenerator->generateFixersDocumentationIndex($fixers)
);
// RuleSet docs.
foreach ((new Finder())->files()->in($locator->getRuleSetsDocumentationDirectoryPath()) as $file) {
$this->filesystem->remove($file->getPathname());
}
$paths = [];
foreach ($setDefinitions as $name => $definition) {
$path = $locator->getRuleSetsDocumentationFilePath($name);
$paths[$path] = $definition;
$this->filesystem->dumpFile($path, $ruleSetDocumentationGenerator->generateRuleSetsDocumentation($definition, $fixers));
}
// RuleSet doc. index
$this->filesystem->dumpFile(
$locator->getRuleSetsDocumentationIndexFilePath(),
$ruleSetDocumentationGenerator->generateRuleSetsDocumentationIndex($paths)
);
$output->writeln('Docs updated.');
return 0;
}
}

View File

@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use PhpCsFixer\Config;
use PhpCsFixer\ConfigInterface;
use PhpCsFixer\ConfigurationException\InvalidConfigurationException;
use PhpCsFixer\Console\Application;
use PhpCsFixer\Console\ConfigurationResolver;
use PhpCsFixer\Console\Output\ErrorOutput;
use PhpCsFixer\Console\Output\OutputContext;
use PhpCsFixer\Console\Output\Progress\ProgressOutputFactory;
use PhpCsFixer\Console\Output\Progress\ProgressOutputType;
use PhpCsFixer\Console\Report\FixReport\ReporterFactory;
use PhpCsFixer\Console\Report\FixReport\ReportSummary;
use PhpCsFixer\Error\ErrorsManager;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\FixerFactory;
use PhpCsFixer\RuleSet\RuleSets;
use PhpCsFixer\Runner\Event\FileProcessed;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
use PhpCsFixer\Runner\Runner;
use PhpCsFixer\ToolInfoInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Terminal;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Stopwatch\Stopwatch;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @final
*
* @internal
*/
#[AsCommand(name: 'fix', description: 'Fixes a directory or a file.')]
/* final */ class FixCommand extends Command
{
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'fix';
/** @TODO PHP 8.0 - remove the property */
protected static $defaultDescription = 'Fixes a directory or a file.';
private EventDispatcherInterface $eventDispatcher;
private ErrorsManager $errorsManager;
private Stopwatch $stopwatch;
private ConfigInterface $defaultConfig;
private ToolInfoInterface $toolInfo;
private ProgressOutputFactory $progressOutputFactory;
public function __construct(ToolInfoInterface $toolInfo)
{
parent::__construct();
$this->eventDispatcher = new EventDispatcher();
$this->errorsManager = new ErrorsManager();
$this->stopwatch = new Stopwatch();
$this->defaultConfig = new Config();
$this->toolInfo = $toolInfo;
$this->progressOutputFactory = new ProgressOutputFactory();
}
/**
* {@inheritdoc}
*
* Override here to only generate the help copy when used.
*/
public function getHelp(): string
{
return <<<'EOF'
The <info>%command.name%</info> command tries to %command.name% as much coding standards
problems as possible on a given file or files in a given directory and its subdirectories:
<info>$ php %command.full_name% /path/to/dir</info>
<info>$ php %command.full_name% /path/to/file</info>
By default <comment>--path-mode</comment> is set to `override`, which means, that if you specify the path to a file or a directory via
command arguments, then the paths provided to a `Finder` in config file will be ignored. You can use <comment>--path-mode=intersection</comment>
to merge paths from the config file and from the argument:
<info>$ php %command.full_name% --path-mode=intersection /path/to/dir</info>
The <comment>--format</comment> option for the output format. Supported formats are `@auto` (default one on v4+), `txt` (default one on v3), `json`, `xml`, `checkstyle`, `junit` and `gitlab`.
* `@auto` aims to auto-select best reporter for given CI or local execution (resolution into best format is outside of BC promise and is future-ready)
* `gitlab` for GitLab
* `@auto,{format}` takes `@auto` under CI, and {format} otherwise
NOTE: the output for the following formats are generated in accordance with schemas
* `checkstyle` follows the common `"checkstyle" XML schema </doc/schemas/fix/checkstyle.xsd>`_
* `gitlab` follows the `codeclimate JSON schema </doc/schemas/fix/codeclimate.json>`_
* `json` follows the `own JSON schema </doc/schemas/fix/schema.json>`_
* `junit` follows the `JUnit XML schema from Jenkins </doc/schemas/fix/junit-10.xsd>`_
* `xml` follows the `own XML schema </doc/schemas/fix/xml.xsd>`_
The <comment>--quiet</comment> Do not output any message.
The <comment>--verbose</comment> option will show the applied rules. When using the `txt` format it will also display progress notifications.
NOTE: if there is an error like "errors reported during linting after fixing", you can use this to be even more verbose for debugging purpose
* `-v`: verbose
* `-vv`: very verbose
* `-vvv`: debug
The <comment>--rules</comment> option limits the rules to apply to the
project:
EOF. /* @TODO: 4.0 - change to @PER */ <<<'EOF'
<info>$ php %command.full_name% /path/to/project --rules=@PSR12</info>
By default the PSR-12 rules are used.
The <comment>--rules</comment> option lets you choose the exact rules to
apply (the rule names must be separated by a comma):
<info>$ php %command.full_name% /path/to/dir --rules=line_ending,full_opening_tag,indentation_type</info>
You can also exclude the rules you don't want by placing a dash in front of the rule name, if this is more convenient,
using <comment>-name_of_fixer</comment>:
<info>$ php %command.full_name% /path/to/dir --rules=-full_opening_tag,-indentation_type</info>
When using combinations of exact and exclude rules, applying exact rules along with above excluded results:
<info>$ php %command.full_name% /path/to/project --rules=@Symfony,-@PSR1,-blank_line_before_statement,strict_comparison</info>
Complete configuration for rules can be supplied using a `json` formatted string.
<info>$ php %command.full_name% /path/to/project --rules='{"concat_space": {"spacing": "none"}}'</info>
The <comment>--dry-run</comment> flag will run the fixer without making changes to your files.
The <comment>--sequential</comment> flag will enforce sequential analysis even if parallel config is provided.
The <comment>--diff</comment> flag can be used to let the fixer output all the changes it makes.
The <comment>--allow-risky</comment> option (pass `yes` or `no`) allows you to set whether risky rules may run. Default value is taken from config file.
A rule is considered risky if it could change code behaviour. By default no risky rules are run.
The <comment>--stop-on-violation</comment> flag stops the execution upon first file that needs to be fixed.
The <comment>--show-progress</comment> option allows you to choose the way process progress is rendered:
* <comment>none</comment>: disables progress output;
* <comment>dots</comment>: multiline progress output with number of files and percentage on each line.
* <comment>bar</comment>: single line progress output with number of files and calculated percentage.
If the option is not provided, it defaults to <comment>bar</comment> unless a config file that disables output is used, in which case it defaults to <comment>none</comment>. This option has no effect if the verbosity of the command is less than <comment>verbose</comment>.
<info>$ php %command.full_name% --verbose --show-progress=dots</info>
By using <comment>--using-cache</comment> option with `yes` or `no` you can set if the caching
mechanism should be used.
The command can also read from standard input, in which case it won't
automatically fix anything:
<info>$ cat foo.php | php %command.full_name% --diff -</info>
Finally, if you don't need BC kept on CLI level, you might use `PHP_CS_FIXER_FUTURE_MODE` to start using options that
would be default in next MAJOR release and to forbid using deprecated configuration:
<info>$ PHP_CS_FIXER_FUTURE_MODE=1 php %command.full_name% -v --diff</info>
Exit code
---------
Exit code of the `%command.name%` command is built using following bit flags:
* 0 - OK.
* 1 - General error (or PHP minimal requirement not matched).
* 4 - Some files have invalid syntax (only in dry-run mode).
* 8 - Some files need fixing (only in dry-run mode).
* 16 - Configuration error of the application.
* 32 - Configuration error of a Fixer.
* 64 - Exception raised within the application.
EOF;
}
protected function configure(): void
{
$reporterFactory = new ReporterFactory();
$reporterFactory->registerBuiltInReporters();
$formats = $reporterFactory->getFormats();
array_unshift($formats, '@auto', '@auto,txt');
$progessOutputTypes = ProgressOutputType::all();
$this->setDefinition(
[
new InputArgument('path', InputArgument::IS_ARRAY, 'The path(s) that rules will be run against (each path can be a file or directory).'),
new InputOption('path-mode', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('Specify path mode (%s).', ConfigurationResolver::PATH_MODE_VALUES), ConfigurationResolver::PATH_MODE_OVERRIDE, ConfigurationResolver::PATH_MODE_VALUES),
new InputOption('allow-risky', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('Are risky fixers allowed (%s).', ConfigurationResolver::BOOL_VALUES), null, ConfigurationResolver::BOOL_VALUES),
new InputOption('config', '', InputOption::VALUE_REQUIRED, 'The path to a config file.'),
new InputOption('dry-run', '', InputOption::VALUE_NONE, 'Only shows which files would have been modified.'),
new InputOption('rules', '', InputOption::VALUE_REQUIRED, 'List of rules that should be run against configured paths.', null, static function () {
$fixerFactory = new FixerFactory();
$fixerFactory->registerBuiltInFixers();
$fixers = array_map(static fn (FixerInterface $fixer) => $fixer->getName(), $fixerFactory->getFixers());
return array_merge(RuleSets::getSetDefinitionNames(), $fixers);
}),
new InputOption('using-cache', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('Should cache be used (%s).', ConfigurationResolver::BOOL_VALUES), null, ConfigurationResolver::BOOL_VALUES),
new InputOption('allow-unsupported-php-version', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('Should the command refuse to run on unsupported PHP version (%s).', ConfigurationResolver::BOOL_VALUES), null, ConfigurationResolver::BOOL_VALUES),
new InputOption('cache-file', '', InputOption::VALUE_REQUIRED, 'The path to the cache file.'),
new InputOption('diff', '', InputOption::VALUE_NONE, 'Prints diff for each file.'),
new InputOption('format', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('To output results in other formats (%s).', $formats), null, $formats),
new InputOption('stop-on-violation', '', InputOption::VALUE_NONE, 'Stop execution on first violation.'),
new InputOption('show-progress', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('Type of progress indicator (%s).', $progessOutputTypes), null, $progessOutputTypes),
new InputOption('sequential', '', InputOption::VALUE_NONE, 'Enforce sequential analysis.'),
]
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$verbosity = $output->getVerbosity();
$passedConfig = $input->getOption('config');
$passedRules = $input->getOption('rules');
if (null !== $passedConfig && null !== $passedRules) {
throw new InvalidConfigurationException('Passing both `--config` and `--rules` options is not allowed.');
}
$resolver = new ConfigurationResolver(
$this->defaultConfig,
[
'allow-risky' => $input->getOption('allow-risky'),
'config' => $passedConfig,
'dry-run' => $this->isDryRun($input),
'rules' => $passedRules,
'path' => $input->getArgument('path'),
'path-mode' => $input->getOption('path-mode'),
'using-cache' => $input->getOption('using-cache'),
'allow-unsupported-php-version' => $input->getOption('allow-unsupported-php-version'),
'cache-file' => $input->getOption('cache-file'),
'format' => $input->getOption('format'),
'diff' => $input->getOption('diff'),
'stop-on-violation' => $input->getOption('stop-on-violation'),
'verbosity' => $verbosity,
'show-progress' => $input->getOption('show-progress'),
'sequential' => $input->getOption('sequential'),
],
getcwd(),
$this->toolInfo
);
$reporter = $resolver->getReporter();
$stdErr = $output instanceof ConsoleOutputInterface
? $output->getErrorOutput()
: ('txt' === $reporter->getFormat() ? $output : null);
if (null !== $stdErr) {
$stdErr->writeln(Application::getAboutWithRuntime(true));
if (version_compare(\PHP_VERSION, ConfigInterface::PHP_VERSION_SYNTAX_SUPPORTED.'.99', '>')) {
$message = \sprintf(
'PHP CS Fixer currently supports PHP syntax only up to PHP %s, current PHP version: %s.',
ConfigInterface::PHP_VERSION_SYNTAX_SUPPORTED,
\PHP_VERSION
);
if (!$resolver->getUnsupportedPhpVersionAllowed()) {
$message .= ' Add Config::setUnsupportedPhpVersionAllowed(true) to allow executions on unsupported PHP versions. Such execution may be unstable and you may experience code modified in a wrong way.';
$stdErr->writeln(\sprintf(
$stdErr->isDecorated() ? '<bg=red;fg=white;>%s</>' : '%s',
$message
));
return 1;
}
$message .= ' Execution may be unstable. You may experience code modified in a wrong way. Please report such cases at https://github.com/PHP-CS-Fixer/PHP-CS-Fixer. Remove Config::setUnsupportedPhpVersionAllowed(true) to allow executions only on supported PHP versions.';
$stdErr->writeln(\sprintf(
$stdErr->isDecorated() ? '<bg=yellow;fg=black;>%s</>' : '%s',
$message
));
}
$isParallel = $resolver->getParallelConfig()->getMaxProcesses() > 1;
$stdErr->writeln(\sprintf(
'Running analysis on %d core%s.',
$resolver->getParallelConfig()->getMaxProcesses(),
$isParallel ? \sprintf(
's with %d file%s per process',
$resolver->getParallelConfig()->getFilesPerProcess(),
$resolver->getParallelConfig()->getFilesPerProcess() > 1 ? 's' : ''
) : ' sequentially'
));
/** @TODO v4 remove warnings related to parallel runner */
$availableMaxProcesses = ParallelConfigFactory::detect()->getMaxProcesses();
if ($isParallel || $availableMaxProcesses > 1) {
$usageDocs = 'https://cs.symfony.com/doc/usage.html';
$stdErr->writeln(\sprintf(
$stdErr->isDecorated() ? '<bg=yellow;fg=black;>%s</>' : '%s',
$isParallel
? 'Parallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!'
: \sprintf(
'You can enable parallel runner and speed up the analysis! Please see %s for more information.',
$stdErr->isDecorated()
? \sprintf('<href=%s;bg=yellow;fg=red;bold>usage docs</>', OutputFormatter::escape($usageDocs))
: $usageDocs
)
));
}
$configFile = $resolver->getConfigFile();
$stdErr->writeln(\sprintf('Loaded config <comment>%s</comment>%s.', $resolver->getConfig()->getName(), null === $configFile ? '' : ' from "'.$configFile.'"'));
if ($resolver->getUsingCache()) {
$cacheFile = $resolver->getCacheFile();
if (is_file($cacheFile)) {
$stdErr->writeln(\sprintf('Using cache file "%s".', $cacheFile));
}
}
}
$finder = new \ArrayIterator(array_filter(
iterator_to_array($resolver->getFinder()),
static fn (\SplFileInfo $fileInfo) => false !== $fileInfo->getRealPath(),
));
if (null !== $stdErr && $resolver->configFinderIsOverridden()) {
$stdErr->writeln(
\sprintf($stdErr->isDecorated() ? '<bg=yellow;fg=black;>%s</>' : '%s', 'Paths from configuration file have been overridden by paths provided as command arguments.')
);
}
$progressType = $resolver->getProgressType();
$progressOutput = $this->progressOutputFactory->create(
$progressType,
new OutputContext(
$stdErr,
(new Terminal())->getWidth(),
\count($finder)
)
);
$runner = new Runner(
$finder,
$resolver->getFixers(),
$resolver->getDiffer(),
ProgressOutputType::NONE !== $progressType ? $this->eventDispatcher : null,
$this->errorsManager,
$resolver->getLinter(),
$resolver->isDryRun(),
$resolver->getCacheManager(),
$resolver->getDirectory(),
$resolver->shouldStopOnViolation(),
$resolver->getParallelConfig(),
$input,
$resolver->getConfigFile()
);
$this->eventDispatcher->addListener(FileProcessed::NAME, [$progressOutput, 'onFixerFileProcessed']);
$this->stopwatch->start('fixFiles');
$changed = $runner->fix();
$this->stopwatch->stop('fixFiles');
$this->eventDispatcher->removeListener(FileProcessed::NAME, [$progressOutput, 'onFixerFileProcessed']);
$progressOutput->printLegend();
$fixEvent = $this->stopwatch->getEvent('fixFiles');
$reportSummary = new ReportSummary(
$changed,
\count($finder),
$fixEvent->getDuration(),
$fixEvent->getMemory(),
OutputInterface::VERBOSITY_VERBOSE <= $verbosity,
$resolver->isDryRun(),
$output->isDecorated()
);
$output->isDecorated()
? $output->write($reporter->generate($reportSummary))
: $output->write($reporter->generate($reportSummary), false, OutputInterface::OUTPUT_RAW);
$invalidErrors = $this->errorsManager->getInvalidErrors();
$exceptionErrors = $this->errorsManager->getExceptionErrors();
$lintErrors = $this->errorsManager->getLintErrors();
if (null !== $stdErr) {
$errorOutput = new ErrorOutput($stdErr);
if (\count($invalidErrors) > 0) {
$errorOutput->listErrors('linting before fixing', $invalidErrors);
}
if (\count($exceptionErrors) > 0) {
$errorOutput->listErrors('fixing', $exceptionErrors);
}
if (\count($lintErrors) > 0) {
$errorOutput->listErrors('linting after fixing', $lintErrors);
}
}
$exitStatusCalculator = new FixCommandExitStatusCalculator();
return $exitStatusCalculator->calculate(
$resolver->isDryRun(),
\count($changed) > 0,
\count($invalidErrors) > 0,
\count($exceptionErrors) > 0,
\count($lintErrors) > 0
);
}
protected function isDryRun(InputInterface $input): bool
{
return $input->getOption('dry-run'); // @phpstan-ignore symfonyConsole.optionNotFound (Because PHPStan doesn't recognise the method is overridden in the child class and this parameter is _not_ used in the child class.)
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class FixCommandExitStatusCalculator
{
// Exit status 1 is reserved for environment constraints not matched.
public const EXIT_STATUS_FLAG_HAS_INVALID_FILES = 4;
public const EXIT_STATUS_FLAG_HAS_CHANGED_FILES = 8;
public const EXIT_STATUS_FLAG_HAS_INVALID_CONFIG = 16;
public const EXIT_STATUS_FLAG_HAS_INVALID_FIXER_CONFIG = 32;
public const EXIT_STATUS_FLAG_EXCEPTION_IN_APP = 64;
public function calculate(
bool $isDryRun,
bool $hasChangedFiles,
bool $hasInvalidErrors,
bool $hasExceptionErrors,
bool $hasLintErrorsAfterFixing
): int {
$exitStatus = 0;
if ($isDryRun) {
if ($hasChangedFiles) {
$exitStatus |= self::EXIT_STATUS_FLAG_HAS_CHANGED_FILES;
}
if ($hasInvalidErrors) {
$exitStatus |= self::EXIT_STATUS_FLAG_HAS_INVALID_FILES;
}
}
if ($hasExceptionErrors || $hasLintErrorsAfterFixing) {
$exitStatus |= self::EXIT_STATUS_FLAG_EXCEPTION_IN_APP;
}
return $exitStatus;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use PhpCsFixer\FixerConfiguration\AllowedValueSubset;
use PhpCsFixer\FixerConfiguration\FixerOptionInterface;
use PhpCsFixer\Utils;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\HelpCommand as BaseHelpCommand;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
#[AsCommand(name: 'help')]
final class HelpCommand extends BaseHelpCommand
{
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'help';
/**
* Formats the description of an option to include its allowed values.
*
* @param string $description description with a single `%s` placeholder for the allowed values
* @param non-empty-list<string> $allowedValues
*/
public static function getDescriptionWithAllowedValues(string $description, array $allowedValues): string
{
$allowedValues = Utils::naturalLanguageJoinWithBackticks($allowedValues, 'or');
return \sprintf($description, 'can be '.$allowedValues);
}
/**
* Returns the allowed values of the given option that can be converted to a string.
*
* @return null|list<AllowedValueSubset|mixed>
*/
public static function getDisplayableAllowedValues(FixerOptionInterface $option): ?array
{
$allowed = $option->getAllowedValues();
if (null !== $allowed) {
$allowed = array_filter($allowed, static fn ($value): bool => !$value instanceof \Closure);
usort($allowed, static function ($valueA, $valueB): int {
if ($valueA instanceof AllowedValueSubset) {
return -1;
}
if ($valueB instanceof AllowedValueSubset) {
return 1;
}
return strcasecmp(
Utils::toString($valueA),
Utils::toString($valueB)
);
});
if (0 === \count($allowed)) {
$allowed = null;
}
}
return $allowed;
}
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$output->getFormatter()->setStyle('url', new OutputFormatterStyle('blue'));
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use PhpCsFixer\Config;
use PhpCsFixer\ConfigInterface;
use PhpCsFixer\Console\ConfigurationResolver;
use PhpCsFixer\ToolInfoInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Path;
/**
* @author Markus Staab <markus.staab@redaxo.org>
*
* @internal
*/
#[AsCommand(name: 'list-files', description: 'List all files being fixed by the given config.')]
final class ListFilesCommand extends Command
{
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'list-files';
/** @TODO PHP 8.0 - remove the property */
protected static $defaultDescription = 'List all files being fixed by the given config.';
private ConfigInterface $defaultConfig;
private ToolInfoInterface $toolInfo;
public function __construct(ToolInfoInterface $toolInfo)
{
parent::__construct();
$this->defaultConfig = new Config();
$this->toolInfo = $toolInfo;
}
protected function configure(): void
{
$this->setDefinition(
[
new InputOption('config', '', InputOption::VALUE_REQUIRED, 'The path to a .php-cs-fixer.php file.'),
]
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$passedConfig = $input->getOption('config');
$cwd = getcwd();
$resolver = new ConfigurationResolver(
$this->defaultConfig,
[
'config' => $passedConfig,
],
$cwd,
$this->toolInfo
);
$finder = $resolver->getFinder();
foreach ($finder as $file) {
if ($file->isFile()) {
$relativePath = './'.Path::makeRelative($file->getRealPath(), $cwd);
// unify directory separators across operating system
$relativePath = str_replace('/', \DIRECTORY_SEPARATOR, $relativePath);
$output->writeln(escapeshellarg($relativePath));
}
}
return 0;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use PhpCsFixer\ConfigurationException\InvalidConfigurationException;
use PhpCsFixer\Console\Report\ListSetsReport\ReporterFactory;
use PhpCsFixer\Console\Report\ListSetsReport\ReporterInterface;
use PhpCsFixer\Console\Report\ListSetsReport\ReportSummary;
use PhpCsFixer\Console\Report\ListSetsReport\TextReporter;
use PhpCsFixer\RuleSet\RuleSets;
use PhpCsFixer\Utils;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
#[AsCommand(name: 'list-sets', description: 'List all available RuleSets.')]
final class ListSetsCommand extends Command
{
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'list-sets';
/** @TODO PHP 8.0 - remove the property */
protected static $defaultDescription = 'List all available RuleSets.';
protected function configure(): void
{
$reporterFactory = new ReporterFactory();
$reporterFactory->registerBuiltInReporters();
$formats = $reporterFactory->getFormats();
\assert([] !== $formats);
$this->setDefinition(
[
new InputOption('format', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('To output results in other formats (%s).', $formats), (new TextReporter())->getFormat(), $formats),
]
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$reporter = $this->resolveReporterWithFactory(
$input->getOption('format'),
new ReporterFactory()
);
$reportSummary = new ReportSummary(
array_values(RuleSets::getSetDefinitions())
);
$report = $reporter->generate($reportSummary);
$output->isDecorated()
? $output->write(OutputFormatter::escape($report))
: $output->write($report, false, OutputInterface::OUTPUT_RAW);
return 0;
}
private function resolveReporterWithFactory(string $format, ReporterFactory $factory): ReporterInterface
{
try {
$factory->registerBuiltInReporters();
$reporter = $factory->getReporter($format);
} catch (\UnexpectedValueException $e) {
$formats = $factory->getFormats();
sort($formats);
throw new InvalidConfigurationException(\sprintf('The format "%s" is not defined, supported are %s.', $format, Utils::naturalLanguageJoin($formats)));
}
return $reporter;
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use PhpCsFixer\Console\Application;
use PhpCsFixer\Console\SelfUpdate\NewVersionCheckerInterface;
use PhpCsFixer\PharCheckerInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\ToolInfoInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @author Igor Wiedler <igor@wiedler.ch>
* @author Stephane PY <py.stephane1@gmail.com>
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
#[AsCommand(name: 'self-update', description: 'Update php-cs-fixer.phar to the latest stable version.')]
final class SelfUpdateCommand extends Command
{
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'self-update';
/** @TODO PHP 8.0 - remove the property */
protected static $defaultDescription = 'Update php-cs-fixer.phar to the latest stable version.';
private NewVersionCheckerInterface $versionChecker;
private ToolInfoInterface $toolInfo;
private PharCheckerInterface $pharChecker;
public function __construct(
NewVersionCheckerInterface $versionChecker,
ToolInfoInterface $toolInfo,
PharCheckerInterface $pharChecker
) {
parent::__construct();
$this->versionChecker = $versionChecker;
$this->toolInfo = $toolInfo;
$this->pharChecker = $pharChecker;
}
/**
* {@inheritdoc}
*
* Override here to only generate the help copy when used.
*/
public function getHelp(): string
{
return <<<'EOT'
The <info>%command.name%</info> command replace your php-cs-fixer.phar by the
latest version released on:
<comment>https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases</comment>
<info>$ php php-cs-fixer.phar %command.name%</info>
EOT;
}
protected function configure(): void
{
$this
->setAliases(['selfupdate'])
->setDefinition(
[
new InputOption('--force', '-f', InputOption::VALUE_NONE, 'Force update to next major version if available.'),
]
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($output instanceof ConsoleOutputInterface) {
$stdErr = $output->getErrorOutput();
$stdErr->writeln(Application::getAboutWithRuntime(true));
}
if (!$this->toolInfo->isInstalledAsPhar()) {
$output->writeln('<error>Self-update is available only for PHAR version.</error>');
return 1;
}
$currentVersion = $this->getApplication()->getVersion();
Preg::match('/^v?(?<major>\d+)\./', $currentVersion, $matches);
$currentMajor = (int) $matches['major'];
try {
$latestVersion = $this->versionChecker->getLatestVersion();
$latestVersionOfCurrentMajor = $this->versionChecker->getLatestVersionOfMajor($currentMajor);
} catch (\Exception $exception) {
$output->writeln(\sprintf(
'<error>Unable to determine newest version: %s</error>',
$exception->getMessage()
));
return 1;
}
if (1 !== $this->versionChecker->compareVersions($latestVersion, $currentVersion)) {
$output->writeln('<info>PHP CS Fixer is already up-to-date.</info>');
return 0;
}
$remoteTag = $latestVersion;
if (
0 !== $this->versionChecker->compareVersions($latestVersionOfCurrentMajor, $latestVersion)
&& true !== $input->getOption('force')
) {
$output->writeln(\sprintf('<info>A new major version of PHP CS Fixer is available</info> (<comment>%s</comment>)', $latestVersion));
$output->writeln(\sprintf('<info>Before upgrading please read</info> https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/%s/UPGRADE-v%s.md', $latestVersion, $currentMajor + 1));
$output->writeln('<info>If you are ready to upgrade run this command with</info> <comment>-f</comment>');
$output->writeln('<info>Checking for new minor/patch version...</info>');
if (1 !== $this->versionChecker->compareVersions($latestVersionOfCurrentMajor, $currentVersion)) {
$output->writeln('<info>No minor update for PHP CS Fixer.</info>');
return 0;
}
$remoteTag = $latestVersionOfCurrentMajor;
}
$localFilename = $_SERVER['argv'][0];
$realPath = realpath($localFilename);
if (false !== $realPath) {
$localFilename = $realPath;
}
if (!is_writable($localFilename)) {
$output->writeln(\sprintf('<error>No permission to update</error> "%s" <error>file.</error>', $localFilename));
return 1;
}
$tempFilename = \dirname($localFilename).'/'.basename($localFilename, '.phar').'-tmp.phar';
$remoteFilename = $this->toolInfo->getPharDownloadUri($remoteTag);
if (false === @copy($remoteFilename, $tempFilename)) {
$output->writeln(\sprintf('<error>Unable to download new version</error> %s <error>from the server.</error>', $remoteTag));
return 1;
}
chmod($tempFilename, 0777 & ~umask());
$pharInvalidityReason = $this->pharChecker->checkFileValidity($tempFilename);
if (null !== $pharInvalidityReason) {
unlink($tempFilename);
$output->writeln(\sprintf('<error>The download of</error> %s <error>is corrupt (%s).</error>', $remoteTag, $pharInvalidityReason));
$output->writeln('<error>Please re-run the "self-update" command to try again.</error>');
return 1;
}
rename($tempFilename, $localFilename);
$output->writeln(\sprintf('<info>PHP CS Fixer updated</info> (<comment>%s</comment> -> <comment>%s</comment>)', $currentVersion, $remoteTag));
return 0;
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Command;
use Clue\React\NDJson\Decoder;
use Clue\React\NDJson\Encoder;
use PhpCsFixer\Cache\NullCacheManager;
use PhpCsFixer\Config;
use PhpCsFixer\Console\ConfigurationResolver;
use PhpCsFixer\Error\ErrorsManager;
use PhpCsFixer\Runner\Event\FileProcessed;
use PhpCsFixer\Runner\Parallel\ParallelAction;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
use PhpCsFixer\Runner\Parallel\ParallelisationException;
use PhpCsFixer\Runner\Runner;
use PhpCsFixer\ToolInfoInterface;
use React\EventLoop\StreamSelectLoop;
use React\Socket\ConnectionInterface;
use React\Socket\TcpConnector;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* @author Greg Korba <greg@codito.dev>
*
* @internal
*/
#[AsCommand(name: 'worker', description: 'Internal command for running fixers in parallel', hidden: true)]
final class WorkerCommand extends Command
{
/** @var string Prefix used before JSON-encoded error printed in the worker's process */
public const ERROR_PREFIX = 'WORKER_ERROR::';
/** @TODO PHP 8.0 - remove the property */
protected static $defaultName = 'worker';
/** @TODO PHP 8.0 - remove the property */
protected static $defaultDescription = 'Internal command for running fixers in parallel';
private ToolInfoInterface $toolInfo;
private ConfigurationResolver $configurationResolver;
private ErrorsManager $errorsManager;
private EventDispatcherInterface $eventDispatcher;
/** @var list<FileProcessed> */
private array $events;
public function __construct(ToolInfoInterface $toolInfo)
{
parent::__construct();
$this->setHidden(true);
$this->toolInfo = $toolInfo;
$this->errorsManager = new ErrorsManager();
$this->eventDispatcher = new EventDispatcher();
}
protected function configure(): void
{
$this->setDefinition(
[
new InputOption('port', null, InputOption::VALUE_REQUIRED, 'Specifies parallelisation server\'s port.'),
new InputOption('identifier', null, InputOption::VALUE_REQUIRED, 'Specifies parallelisation process\' identifier.'),
new InputOption('allow-risky', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('Are risky fixers allowed (%s).', ConfigurationResolver::BOOL_VALUES), null, ConfigurationResolver::BOOL_VALUES),
new InputOption('config', '', InputOption::VALUE_REQUIRED, 'The path to a config file.'),
new InputOption('dry-run', '', InputOption::VALUE_NONE, 'Only shows which files would have been modified.'),
new InputOption('rules', '', InputOption::VALUE_REQUIRED, 'List of rules that should be run against configured paths.'),
new InputOption('using-cache', '', InputOption::VALUE_REQUIRED, HelpCommand::getDescriptionWithAllowedValues('Should cache be used (%s).', ConfigurationResolver::BOOL_VALUES), null, ConfigurationResolver::BOOL_VALUES),
new InputOption('cache-file', '', InputOption::VALUE_REQUIRED, 'The path to the cache file.'),
new InputOption('diff', '', InputOption::VALUE_NONE, 'Prints diff for each file.'),
new InputOption('stop-on-violation', '', InputOption::VALUE_NONE, 'Stop execution on first violation.'),
]
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
$identifier = $input->getOption('identifier');
$port = $input->getOption('port');
if (null === $identifier || !is_numeric($port)) {
throw new ParallelisationException('Missing parallelisation options');
}
try {
$runner = $this->createRunner($input);
} catch (\Throwable $e) {
throw new ParallelisationException('Unable to create runner: '.$e->getMessage(), 0, $e);
}
$loop = new StreamSelectLoop();
$tcpConnector = new TcpConnector($loop);
$tcpConnector
->connect(\sprintf('127.0.0.1:%d', $port))
->then(
/** @codeCoverageIgnore */
function (ConnectionInterface $connection) use ($loop, $runner, $identifier): void {
$out = new Encoder($connection, \JSON_INVALID_UTF8_IGNORE);
$in = new Decoder($connection, true, 512, \JSON_INVALID_UTF8_IGNORE);
// [REACT] Initialise connection with the parallelisation operator
$out->write(['action' => ParallelAction::WORKER_HELLO, 'identifier' => $identifier]);
$handleError = static function (\Throwable $error) use ($out): void {
$out->write([
'action' => ParallelAction::WORKER_ERROR_REPORT,
'class' => \get_class($error),
'message' => $error->getMessage(),
'file' => $error->getFile(),
'line' => $error->getLine(),
'code' => $error->getCode(),
'trace' => $error->getTraceAsString(),
]);
};
$out->on('error', $handleError);
$in->on('error', $handleError);
// [REACT] Listen for messages from the parallelisation operator (analysis requests)
$in->on('data', function (array $json) use ($loop, $runner, $out): void {
$action = $json['action'] ?? null;
// Parallelisation operator does not have more to do, let's close the connection
if (ParallelAction::RUNNER_THANK_YOU === $action) {
$loop->stop();
return;
}
if (ParallelAction::RUNNER_REQUEST_ANALYSIS !== $action) {
// At this point we only expect analysis requests, if any other action happen, we need to fix the code.
throw new \LogicException(\sprintf('Unexpected action ParallelAction::%s.', $action));
}
/** @var iterable<int, string> $files */
$files = $json['files'];
foreach ($files as $path) {
// Reset events because we want to collect only those coming from analysed files chunk
$this->events = [];
$runner->setFileIterator(new \ArrayIterator([new \SplFileInfo($path)]));
$analysisResult = $runner->fix();
if (1 !== \count($this->events)) {
throw new ParallelisationException('Runner did not report a fixing event or reported too many.');
}
if (1 < \count($analysisResult)) {
throw new ParallelisationException('Runner returned more analysis results than expected.');
}
$out->write([
'action' => ParallelAction::WORKER_RESULT,
'file' => $path,
'fileHash' => $this->events[0]->getFileHash(),
'status' => $this->events[0]->getStatus(),
'fixInfo' => array_pop($analysisResult),
'errors' => $this->errorsManager->forPath($path),
]);
}
// Request another file chunk (if available, the parallelisation operator will request new "run" action)
$out->write(['action' => ParallelAction::WORKER_GET_FILE_CHUNK]);
});
},
static function (\Throwable $error) use ($errorOutput): void {
// @TODO Verify onRejected behaviour → https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7777#discussion_r1590399285
$errorOutput->writeln($error->getMessage());
}
)
;
$loop->run();
return Command::SUCCESS;
}
private function createRunner(InputInterface $input): Runner
{
$passedConfig = $input->getOption('config');
$passedRules = $input->getOption('rules');
if (null !== $passedConfig && null !== $passedRules) {
throw new \RuntimeException('Passing both `--config` and `--rules` options is not allowed');
}
// There's no one single source of truth when it comes to fixing single file, we need to collect statuses from events.
$this->eventDispatcher->addListener(FileProcessed::NAME, function (FileProcessed $event): void {
$this->events[] = $event;
});
$this->configurationResolver = new ConfigurationResolver(
new Config(),
[
'allow-risky' => $input->getOption('allow-risky'),
'config' => $passedConfig,
'dry-run' => $input->getOption('dry-run'),
'rules' => $passedRules,
'path' => [],
'path-mode' => ConfigurationResolver::PATH_MODE_OVERRIDE, // IMPORTANT! WorkerCommand is called with file that already passed filtering, so here we can rely on PATH_MODE_OVERRIDE.
'using-cache' => $input->getOption('using-cache'),
'cache-file' => $input->getOption('cache-file'),
'diff' => $input->getOption('diff'),
'stop-on-violation' => $input->getOption('stop-on-violation'),
],
getcwd(), // @phpstan-ignore-line
$this->toolInfo
);
return new Runner(
null, // Paths are known when parallelisation server requests new chunk, not now
$this->configurationResolver->getFixers(),
$this->configurationResolver->getDiffer(),
$this->eventDispatcher,
$this->errorsManager,
$this->configurationResolver->getLinter(),
$this->configurationResolver->isDryRun(),
new NullCacheManager(), // IMPORTANT! We pass null cache, as cache is read&write in main process and we do not need to do it again.
$this->configurationResolver->getDirectory(),
$this->configurationResolver->shouldStopOnViolation(),
ParallelConfigFactory::sequential(), // IMPORTANT! Worker must run in sequential mode.
null,
$this->configurationResolver->getConfigFile()
);
}
}

View File

@@ -0,0 +1,997 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console;
use PhpCsFixer\Cache\CacheManagerInterface;
use PhpCsFixer\Cache\Directory;
use PhpCsFixer\Cache\DirectoryInterface;
use PhpCsFixer\Cache\FileCacheManager;
use PhpCsFixer\Cache\FileHandler;
use PhpCsFixer\Cache\NullCacheManager;
use PhpCsFixer\Cache\Signature;
use PhpCsFixer\ConfigInterface;
use PhpCsFixer\ConfigurationException\InvalidConfigurationException;
use PhpCsFixer\Console\Output\Progress\ProgressOutputType;
use PhpCsFixer\Console\Report\FixReport\ReporterFactory;
use PhpCsFixer\Console\Report\FixReport\ReporterInterface;
use PhpCsFixer\Differ\DifferInterface;
use PhpCsFixer\Differ\NullDiffer;
use PhpCsFixer\Differ\UnifiedDiffer;
use PhpCsFixer\Finder;
use PhpCsFixer\Fixer\DeprecatedFixerInterface;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\FixerFactory;
use PhpCsFixer\Linter\Linter;
use PhpCsFixer\Linter\LinterInterface;
use PhpCsFixer\ParallelAwareConfigInterface;
use PhpCsFixer\RuleSet\RuleSet;
use PhpCsFixer\RuleSet\RuleSetInterface;
use PhpCsFixer\Runner\Parallel\ParallelConfig;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
use PhpCsFixer\StdinFileInfo;
use PhpCsFixer\ToolInfoInterface;
use PhpCsFixer\UnsupportedPhpVersionAllowedConfigInterface;
use PhpCsFixer\Utils;
use PhpCsFixer\WhitespacesFixerConfig;
use PhpCsFixer\WordMatcher;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder as SymfonyFinder;
/**
* The resolver that resolves configuration to use by command line options and config.
*
* @internal
*
* @phpstan-type _Options array{
* allow-risky: null|string,
* cache-file: null|string,
* config: null|string,
* diff: null|string,
* dry-run: null|bool,
* format: null|string,
* path: list<string>,
* path-mode: value-of<self::PATH_MODE_VALUES>,
* rules: null|string,
* sequential: null|string,
* show-progress: null|string,
* stop-on-violation: null|bool,
* using-cache: null|string,
* allow-unsupported-php-version: null|bool,
* verbosity: null|string,
* }
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Katsuhiro Ogawa <ko.fivestar@gmail.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
final class ConfigurationResolver
{
public const PATH_MODE_OVERRIDE = 'override';
public const PATH_MODE_INTERSECTION = 'intersection';
public const PATH_MODE_VALUES = [
self::PATH_MODE_OVERRIDE,
self::PATH_MODE_INTERSECTION,
];
public const BOOL_YES = 'yes';
public const BOOL_NO = 'no';
public const BOOL_VALUES = [
self::BOOL_YES,
self::BOOL_NO,
];
private ?bool $allowRisky = null;
private ?ConfigInterface $config = null;
private ?string $configFile = null;
private string $cwd;
private ConfigInterface $defaultConfig;
private ?ReporterInterface $reporter = null;
private ?bool $isStdIn = null;
private ?bool $isDryRun = null;
/**
* @var null|list<FixerInterface>
*/
private ?array $fixers = null;
private ?bool $configFinderIsOverridden = null;
private ToolInfoInterface $toolInfo;
/**
* @var _Options
*/
private array $options = [
'allow-risky' => null,
'cache-file' => null,
'config' => null,
'diff' => null,
'dry-run' => null,
'format' => null,
'path' => [],
'path-mode' => self::PATH_MODE_OVERRIDE,
'rules' => null,
'sequential' => null,
'show-progress' => null,
'stop-on-violation' => null,
'using-cache' => null,
'allow-unsupported-php-version' => null,
'verbosity' => null,
];
private ?string $cacheFile = null;
private ?CacheManagerInterface $cacheManager = null;
private ?DifferInterface $differ = null;
private ?Directory $directory = null;
/**
* @var null|iterable<\SplFileInfo>
*/
private ?iterable $finder = null;
private ?string $format = null;
private ?Linter $linter = null;
/**
* @var null|list<string>
*/
private ?array $path = null;
/**
* @var null|ProgressOutputType::*
*/
private $progress;
private ?RuleSet $ruleSet = null;
private ?bool $usingCache = null;
private ?bool $isUnsupportedPhpVersionAllowed = null;
private ?FixerFactory $fixerFactory = null;
/**
* @param array<string, mixed> $options
*/
public function __construct(
ConfigInterface $config,
array $options,
string $cwd,
ToolInfoInterface $toolInfo
) {
$this->defaultConfig = $config;
$this->cwd = $cwd;
$this->toolInfo = $toolInfo;
foreach ($options as $name => $value) {
$this->setOption($name, $value);
}
}
public function getCacheFile(): ?string
{
if (!$this->getUsingCache()) {
return null;
}
if (null === $this->cacheFile) {
if (null === $this->options['cache-file']) {
$this->cacheFile = $this->getConfig()->getCacheFile();
} else {
$this->cacheFile = $this->options['cache-file'];
}
}
return $this->cacheFile;
}
public function getCacheManager(): CacheManagerInterface
{
if (null === $this->cacheManager) {
$cacheFile = $this->getCacheFile();
if (null === $cacheFile) {
$this->cacheManager = new NullCacheManager();
} else {
$this->cacheManager = new FileCacheManager(
new FileHandler($cacheFile),
new Signature(
\PHP_VERSION,
$this->toolInfo->getVersion(),
$this->getConfig()->getIndent(),
$this->getConfig()->getLineEnding(),
$this->getRules()
),
$this->isDryRun(),
$this->getDirectory()
);
}
}
return $this->cacheManager;
}
public function getConfig(): ConfigInterface
{
if (null === $this->config) {
foreach ($this->computeConfigFiles() as $configFile) {
if (!file_exists($configFile)) {
continue;
}
$configFileBasename = basename($configFile);
/** @TODO v4 drop handling (triggering error) for v2 config names */
$deprecatedConfigs = [
'.php_cs' => '.php-cs-fixer.php',
'.php_cs.dist' => '.php-cs-fixer.dist.php',
];
if (isset($deprecatedConfigs[$configFileBasename])) {
throw new InvalidConfigurationException("Configuration file `{$configFileBasename}` is outdated, rename to `{$deprecatedConfigs[$configFileBasename]}`.");
}
$this->config = self::separatedContextLessInclude($configFile);
$this->configFile = $configFile;
break;
}
if (null === $this->config) {
$this->config = $this->defaultConfig;
}
}
return $this->config;
}
public function getParallelConfig(): ParallelConfig
{
$config = $this->getConfig();
return true !== $this->options['sequential'] && $config instanceof ParallelAwareConfigInterface
? $config->getParallelConfig()
: ParallelConfigFactory::sequential();
}
public function getConfigFile(): ?string
{
if (null === $this->configFile) {
$this->getConfig();
}
return $this->configFile;
}
public function getDiffer(): DifferInterface
{
if (null === $this->differ) {
$this->differ = (true === $this->options['diff']) ? new UnifiedDiffer() : new NullDiffer();
}
return $this->differ;
}
public function getDirectory(): DirectoryInterface
{
if (null === $this->directory) {
$path = $this->getCacheFile();
if (null === $path) {
$absolutePath = $this->cwd;
} else {
$filesystem = new Filesystem();
$absolutePath = $filesystem->isAbsolutePath($path)
? $path
: $this->cwd.\DIRECTORY_SEPARATOR.$path;
$absolutePath = \dirname($absolutePath);
}
$this->directory = new Directory($absolutePath);
}
return $this->directory;
}
/**
* @return list<FixerInterface>
*/
public function getFixers(): array
{
if (null === $this->fixers) {
$this->fixers = $this->createFixerFactory()
->useRuleSet($this->getRuleSet())
->setWhitespacesConfig(new WhitespacesFixerConfig($this->config->getIndent(), $this->config->getLineEnding()))
->getFixers()
;
if (false === $this->getRiskyAllowed()) {
$riskyFixers = array_map(
static fn (FixerInterface $fixer): string => $fixer->getName(),
array_filter(
$this->fixers,
static fn (FixerInterface $fixer): bool => $fixer->isRisky()
)
);
if (\count($riskyFixers) > 0) {
throw new InvalidConfigurationException(\sprintf('The rules contain risky fixers (%s), but they are not allowed to run. Perhaps you forget to use --allow-risky=yes option?', Utils::naturalLanguageJoin($riskyFixers)));
}
}
}
return $this->fixers;
}
public function getLinter(): LinterInterface
{
if (null === $this->linter) {
$this->linter = new Linter();
}
return $this->linter;
}
/**
* Returns path.
*
* @return list<string>
*/
public function getPath(): array
{
if (null === $this->path) {
$filesystem = new Filesystem();
$cwd = $this->cwd;
if (1 === \count($this->options['path']) && '-' === $this->options['path'][0]) {
$this->path = $this->options['path'];
} else {
$this->path = array_map(
static function (string $rawPath) use ($cwd, $filesystem): string {
$path = trim($rawPath);
if ('' === $path) {
throw new InvalidConfigurationException("Invalid path: \"{$rawPath}\".");
}
$absolutePath = $filesystem->isAbsolutePath($path)
? $path
: $cwd.\DIRECTORY_SEPARATOR.$path;
if (!file_exists($absolutePath)) {
throw new InvalidConfigurationException(\sprintf(
'The path "%s" is not readable.',
$path
));
}
return $absolutePath;
},
$this->options['path']
);
}
}
return $this->path;
}
/**
* @return ProgressOutputType::*
*
* @throws InvalidConfigurationException
*/
public function getProgressType(): string
{
if (null === $this->progress) {
if ('txt' === $this->resolveFormat()) {
$progressType = $this->options['show-progress'];
if (null === $progressType) {
$progressType = $this->getConfig()->getHideProgress()
? ProgressOutputType::NONE
: ProgressOutputType::BAR;
} elseif (!\in_array($progressType, ProgressOutputType::all(), true)) {
throw new InvalidConfigurationException(\sprintf(
'The progress type "%s" is not defined, supported are %s.',
$progressType,
Utils::naturalLanguageJoin(ProgressOutputType::all())
));
}
$this->progress = $progressType;
} else {
$this->progress = ProgressOutputType::NONE;
}
}
return $this->progress;
}
public function getReporter(): ReporterInterface
{
if (null === $this->reporter) {
$reporterFactory = new ReporterFactory();
$reporterFactory->registerBuiltInReporters();
$format = $this->resolveFormat();
try {
$this->reporter = $reporterFactory->getReporter($format);
} catch (\UnexpectedValueException $e) {
$formats = $reporterFactory->getFormats();
sort($formats);
throw new InvalidConfigurationException(\sprintf('The format "%s" is not defined, supported are %s.', $format, Utils::naturalLanguageJoin($formats)));
}
}
return $this->reporter;
}
public function getRiskyAllowed(): bool
{
if (null === $this->allowRisky) {
if (null === $this->options['allow-risky']) {
$this->allowRisky = $this->getConfig()->getRiskyAllowed();
} else {
$this->allowRisky = $this->resolveOptionBooleanValue('allow-risky');
}
}
return $this->allowRisky;
}
/**
* Returns rules.
*
* @return array<string, array<string, mixed>|bool>
*/
public function getRules(): array
{
return $this->getRuleSet()->getRules();
}
public function getUsingCache(): bool
{
if (null === $this->usingCache) {
if (null === $this->options['using-cache']) {
$this->usingCache = $this->getConfig()->getUsingCache();
} else {
$this->usingCache = $this->resolveOptionBooleanValue('using-cache');
}
}
$this->usingCache = $this->usingCache && $this->isCachingAllowedForRuntime();
return $this->usingCache;
}
public function getUnsupportedPhpVersionAllowed(): bool
{
if (null === $this->isUnsupportedPhpVersionAllowed) {
if (null === $this->options['allow-unsupported-php-version']) {
$config = $this->getConfig();
$this->isUnsupportedPhpVersionAllowed = $config instanceof UnsupportedPhpVersionAllowedConfigInterface
? $config->getUnsupportedPhpVersionAllowed()
: false;
} else {
$this->isUnsupportedPhpVersionAllowed = $this->resolveOptionBooleanValue('allow-unsupported-php-version');
}
}
return $this->isUnsupportedPhpVersionAllowed;
}
/**
* @return iterable<\SplFileInfo>
*/
public function getFinder(): iterable
{
if (null === $this->finder) {
$this->finder = $this->resolveFinder();
}
return $this->finder;
}
/**
* Returns dry-run flag.
*/
public function isDryRun(): bool
{
if (null === $this->isDryRun) {
if ($this->isStdIn()) {
// Can't write to STDIN
$this->isDryRun = true;
} else {
$this->isDryRun = $this->options['dry-run'];
}
}
return $this->isDryRun;
}
public function shouldStopOnViolation(): bool
{
return $this->options['stop-on-violation'];
}
public function configFinderIsOverridden(): bool
{
if (null === $this->configFinderIsOverridden) {
$this->resolveFinder();
}
return $this->configFinderIsOverridden;
}
/**
* Compute file candidates for config file.
*
* @return list<string>
*/
private function computeConfigFiles(): array
{
$configFile = $this->options['config'];
if (null !== $configFile) {
if (false === file_exists($configFile) || false === is_readable($configFile)) {
throw new InvalidConfigurationException(\sprintf('Cannot read config file "%s".', $configFile));
}
return [$configFile];
}
$path = $this->getPath();
if ($this->isStdIn() || 0 === \count($path)) {
$configDir = $this->cwd;
} elseif (1 < \count($path)) {
throw new InvalidConfigurationException('For multiple paths config parameter is required.');
} elseif (!is_file($path[0])) {
$configDir = $path[0];
} else {
$dirName = pathinfo($path[0], \PATHINFO_DIRNAME);
$configDir = is_dir($dirName) ? $dirName : $path[0];
}
$candidates = [
$configDir.\DIRECTORY_SEPARATOR.'.php-cs-fixer.php',
$configDir.\DIRECTORY_SEPARATOR.'.php-cs-fixer.dist.php',
// @TODO v4 drop handling (triggering error) for v2 config names
$configDir.\DIRECTORY_SEPARATOR.'.php_cs', // old v2 config, present here only to throw nice error message later
$configDir.\DIRECTORY_SEPARATOR.'.php_cs.dist', // old v2 config, present here only to throw nice error message later
];
if ($configDir !== $this->cwd) {
$candidates[] = $this->cwd.\DIRECTORY_SEPARATOR.'.php-cs-fixer.php';
$candidates[] = $this->cwd.\DIRECTORY_SEPARATOR.'.php-cs-fixer.dist.php';
// @TODO v4 drop handling (triggering error) for v2 config names
$candidates[] = $this->cwd.\DIRECTORY_SEPARATOR.'.php_cs'; // old v2 config, present here only to throw nice error message later
$candidates[] = $this->cwd.\DIRECTORY_SEPARATOR.'.php_cs.dist'; // old v2 config, present here only to throw nice error message later
}
return $candidates;
}
private function createFixerFactory(): FixerFactory
{
if (null === $this->fixerFactory) {
$fixerFactory = new FixerFactory();
$fixerFactory->registerBuiltInFixers();
$fixerFactory->registerCustomFixers($this->getConfig()->getCustomFixers());
$this->fixerFactory = $fixerFactory;
}
return $this->fixerFactory;
}
private function resolveFormat(): string
{
if (null === $this->format) {
$formatCandidate = $this->options['format'] ?? $this->getConfig()->getFormat();
$parts = explode(',', $formatCandidate);
if (\count($parts) > 2) {
throw new InvalidConfigurationException(\sprintf('The format "%s" is invalid.', $formatCandidate));
}
$this->format = $parts[0];
if ('@auto' === $this->format) {
$this->format = $parts[1] ?? 'txt';
if (filter_var(getenv('GITLAB_CI'), \FILTER_VALIDATE_BOOL)) {
$this->format = 'gitlab';
}
}
}
return $this->format;
}
private function getRuleSet(): RuleSetInterface
{
if (null === $this->ruleSet) {
$rules = $this->parseRules();
$this->validateRules($rules);
$this->ruleSet = new RuleSet($rules);
}
return $this->ruleSet;
}
private function isStdIn(): bool
{
if (null === $this->isStdIn) {
$this->isStdIn = 1 === \count($this->options['path']) && '-' === $this->options['path'][0];
}
return $this->isStdIn;
}
/**
* @template T
*
* @param iterable<T> $iterable
*
* @return \Traversable<T>
*/
private function iterableToTraversable(iterable $iterable): \Traversable
{
return \is_array($iterable) ? new \ArrayIterator($iterable) : $iterable;
}
/**
* @return array<string, mixed>
*/
private function parseRules(): array
{
if (null === $this->options['rules']) {
return $this->getConfig()->getRules();
}
$rules = trim($this->options['rules']);
if ('' === $rules) {
throw new InvalidConfigurationException('Empty rules value is not allowed.');
}
if (str_starts_with($rules, '{')) {
$rules = json_decode($rules, true);
if (\JSON_ERROR_NONE !== json_last_error()) {
throw new InvalidConfigurationException(\sprintf('Invalid JSON rules input: "%s".', json_last_error_msg()));
}
return $rules;
}
$rules = [];
foreach (explode(',', $this->options['rules']) as $rule) {
$rule = trim($rule);
if ('' === $rule) {
throw new InvalidConfigurationException('Empty rule name is not allowed.');
}
if (str_starts_with($rule, '-')) {
$rules[substr($rule, 1)] = false;
} else {
$rules[$rule] = true;
}
}
return $rules;
}
/**
* @param array<string, mixed> $rules
*
* @throws InvalidConfigurationException
*/
private function validateRules(array $rules): void
{
/**
* Create a ruleset that contains all configured rules, even when they originally have been disabled.
*
* @see RuleSet::resolveSet()
*/
$ruleSet = [];
foreach ($rules as $key => $value) {
if (\is_int($key)) {
throw new InvalidConfigurationException(\sprintf('Missing value for "%s" rule/set.', $value));
}
$ruleSet[$key] = true;
}
$ruleSet = new RuleSet($ruleSet);
$configuredFixers = array_keys($ruleSet->getRules());
$fixers = $this->createFixerFactory()->getFixers();
$availableFixers = array_map(static fn (FixerInterface $fixer): string => $fixer->getName(), $fixers);
$unknownFixers = array_diff($configuredFixers, $availableFixers);
if (\count($unknownFixers) > 0) {
$renamedRules = [
'blank_line_before_return' => [
'new_name' => 'blank_line_before_statement',
'config' => ['statements' => ['return']],
],
'final_static_access' => [
'new_name' => 'self_static_accessor',
],
'hash_to_slash_comment' => [
'new_name' => 'single_line_comment_style',
'config' => ['comment_types' => ['hash']],
],
'lowercase_constants' => [
'new_name' => 'constant_case',
'config' => ['case' => 'lower'],
],
'no_extra_consecutive_blank_lines' => [
'new_name' => 'no_extra_blank_lines',
],
'no_multiline_whitespace_before_semicolons' => [
'new_name' => 'multiline_whitespace_before_semicolons',
],
'no_short_echo_tag' => [
'new_name' => 'echo_tag_syntax',
'config' => ['format' => 'long'],
],
'php_unit_ordered_covers' => [
'new_name' => 'phpdoc_order_by_value',
'config' => ['annotations' => ['covers']],
],
'phpdoc_inline_tag' => [
'new_name' => 'general_phpdoc_tag_rename, phpdoc_inline_tag_normalizer and phpdoc_tag_type',
],
'pre_increment' => [
'new_name' => 'increment_style',
'config' => ['style' => 'pre'],
],
'psr0' => [
'new_name' => 'psr_autoloading',
'config' => ['dir' => 'x'],
],
'psr4' => [
'new_name' => 'psr_autoloading',
],
'silenced_deprecation_error' => [
'new_name' => 'error_suppression',
],
'trailing_comma_in_multiline_array' => [
'new_name' => 'trailing_comma_in_multiline',
'config' => ['elements' => ['arrays']],
],
];
$message = 'The rules contain unknown fixers: ';
$hasOldRule = false;
foreach ($unknownFixers as $unknownFixer) {
if (isset($renamedRules[$unknownFixer])) { // Check if present as old renamed rule
$hasOldRule = true;
$message .= \sprintf(
'"%s" is renamed (did you mean "%s"?%s), ',
$unknownFixer,
$renamedRules[$unknownFixer]['new_name'],
isset($renamedRules[$unknownFixer]['config']) ? ' (note: use configuration "'.Utils::toString($renamedRules[$unknownFixer]['config']).'")' : ''
);
} else { // Go to normal matcher if it is not a renamed rule
$matcher = new WordMatcher($availableFixers);
$alternative = $matcher->match($unknownFixer);
$message .= \sprintf(
'"%s"%s, ',
$unknownFixer,
null === $alternative ? '' : ' (did you mean "'.$alternative.'"?)'
);
}
}
$message = substr($message, 0, -2).'.';
if ($hasOldRule) {
$message .= "\nFor more info about updating see: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/v3.0.0/UPGRADE-v3.md#renamed-ruless.";
}
throw new InvalidConfigurationException($message);
}
foreach ($fixers as $fixer) {
$fixerName = $fixer->getName();
if (isset($rules[$fixerName]) && $fixer instanceof DeprecatedFixerInterface) {
$successors = $fixer->getSuccessorsNames();
$messageEnd = [] === $successors
? \sprintf(' and will be removed in version %d.0.', Application::getMajorVersion() + 1)
: \sprintf('. Use %s instead.', str_replace('`', '"', Utils::naturalLanguageJoinWithBackticks($successors)));
Utils::triggerDeprecation(new \RuntimeException("Rule \"{$fixerName}\" is deprecated{$messageEnd}"));
}
}
}
/**
* Apply path on config instance.
*
* @return iterable<\SplFileInfo>
*/
private function resolveFinder(): iterable
{
$this->configFinderIsOverridden = false;
if ($this->isStdIn()) {
return new \ArrayIterator([new StdinFileInfo()]);
}
if (!\in_array(
$this->options['path-mode'],
self::PATH_MODE_VALUES,
true
)) {
throw new InvalidConfigurationException(\sprintf(
'The path-mode "%s" is not defined, supported are %s.',
$this->options['path-mode'],
Utils::naturalLanguageJoin(self::PATH_MODE_VALUES)
));
}
$isIntersectionPathMode = self::PATH_MODE_INTERSECTION === $this->options['path-mode'];
$paths = array_map(
static fn (string $path) => realpath($path),
$this->getPath()
);
if (0 === \count($paths)) {
if ($isIntersectionPathMode) {
return new \ArrayIterator([]);
}
return $this->iterableToTraversable($this->getConfig()->getFinder());
}
$pathsByType = [
'file' => [],
'dir' => [],
];
foreach ($paths as $path) {
if (is_file($path)) {
$pathsByType['file'][] = $path;
} else {
$pathsByType['dir'][] = $path.\DIRECTORY_SEPARATOR;
}
}
$nestedFinder = null;
$currentFinder = $this->iterableToTraversable($this->getConfig()->getFinder());
try {
$nestedFinder = $currentFinder instanceof \IteratorAggregate ? $currentFinder->getIterator() : $currentFinder;
} catch (\Exception $e) {
}
if ($isIntersectionPathMode) {
if (null === $nestedFinder) {
throw new InvalidConfigurationException(
'Cannot create intersection with not-fully defined Finder in configuration file.'
);
}
return new \CallbackFilterIterator(
new \IteratorIterator($nestedFinder),
static function (\SplFileInfo $current) use ($pathsByType): bool {
$currentRealPath = $current->getRealPath();
if (\in_array($currentRealPath, $pathsByType['file'], true)) {
return true;
}
foreach ($pathsByType['dir'] as $path) {
if (str_starts_with($currentRealPath, $path)) {
return true;
}
}
return false;
}
);
}
if (null !== $this->getConfigFile() && null !== $nestedFinder) {
$this->configFinderIsOverridden = true;
}
if ($currentFinder instanceof SymfonyFinder && null === $nestedFinder) {
// finder from configuration Symfony finder and it is not fully defined, we may fulfill it
return $currentFinder->in($pathsByType['dir'])->append($pathsByType['file']);
}
return Finder::create()->in($pathsByType['dir'])->append($pathsByType['file']);
}
/**
* Set option that will be resolved.
*
* @param mixed $value
*/
private function setOption(string $name, $value): void
{
if (!\array_key_exists($name, $this->options)) {
throw new InvalidConfigurationException(\sprintf('Unknown option name: "%s".', $name));
}
$this->options[$name] = $value;
}
/**
* @param key-of<_Options> $optionName
*/
private function resolveOptionBooleanValue(string $optionName): bool
{
$value = $this->options[$optionName];
if (self::BOOL_YES === $value) {
return true;
}
if (self::BOOL_NO === $value) {
return false;
}
throw new InvalidConfigurationException(\sprintf('Expected "%s" or "%s" for option "%s", got "%s".', self::BOOL_YES, self::BOOL_NO, $optionName, \is_object($value) ? \get_class($value) : (\is_scalar($value) ? $value : \gettype($value))));
}
private static function separatedContextLessInclude(string $path): ConfigInterface
{
$config = include $path;
// verify that the config has an instance of Config
if (!$config instanceof ConfigInterface) {
throw new InvalidConfigurationException(\sprintf('The config file: "%s" does not return a "PhpCsFixer\ConfigInterface" instance. Got: "%s".', $path, \is_object($config) ? \get_class($config) : \gettype($config)));
}
return $config;
}
private function isCachingAllowedForRuntime(): bool
{
return $this->toolInfo->isInstalledAsPhar()
|| $this->toolInfo->isInstalledByComposer()
|| $this->toolInfo->isRunInsideDocker()
|| filter_var(getenv('PHP_CS_FIXER_ENFORCE_CACHE'), \FILTER_VALIDATE_BOOL);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Output;
use PhpCsFixer\Differ\DiffConsoleFormatter;
use PhpCsFixer\Error\Error;
use PhpCsFixer\Linter\LintingException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @readonly
*
* @internal
*/
final class ErrorOutput
{
private OutputInterface $output;
private bool $isDecorated;
public function __construct(OutputInterface $output)
{
$this->output = $output;
$this->isDecorated = $output->isDecorated();
}
/**
* @param list<Error> $errors
*/
public function listErrors(string $process, array $errors): void
{
$this->output->writeln(['', \sprintf(
'Files that were not fixed due to errors reported during %s:',
$process
)]);
$showDetails = $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE;
$showTrace = $this->output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG;
foreach ($errors as $i => $error) {
$this->output->writeln(\sprintf('%4d) %s', $i + 1, $error->getFilePath()));
$e = $error->getSource();
if (!$showDetails || null === $e) {
continue;
}
$class = \sprintf('[%s]', \get_class($e));
$message = $e->getMessage();
$code = $e->getCode();
if (0 !== $code) {
$message .= " ({$code})";
}
$length = max(\strlen($class), \strlen($message));
$lines = [
'',
$class,
$message,
'',
];
$this->output->writeln('');
foreach ($lines as $line) {
if (\strlen($line) < $length) {
$line .= str_repeat(' ', $length - \strlen($line));
}
$this->output->writeln(\sprintf(' <error> %s </error>', $this->prepareOutput($line)));
}
if ($showTrace && !$e instanceof LintingException) { // stack trace of lint exception is of no interest
$this->output->writeln('');
$stackTrace = $e->getTrace();
foreach ($stackTrace as $trace) {
if (isset($trace['class']) && Command::class === $trace['class'] && 'run' === $trace['function']) {
$this->output->writeln(' [ ... ]');
break;
}
$this->outputTrace($trace);
}
}
if (Error::TYPE_LINT === $error->getType() && 0 < \count($error->getAppliedFixers())) {
$this->output->writeln('');
$this->output->writeln(\sprintf(' Applied fixers: <comment>%s</comment>', implode(', ', $error->getAppliedFixers())));
$diff = $error->getDiff();
if (null !== $diff) {
$diffFormatter = new DiffConsoleFormatter(
$this->isDecorated,
\sprintf(
'<comment> ---------- begin diff ----------</comment>%s%%s%s<comment> ----------- end diff -----------</comment>',
\PHP_EOL,
\PHP_EOL
)
);
$this->output->writeln($diffFormatter->format($diff));
}
}
}
}
/**
* @param array{
* function?: string,
* line?: int,
* file?: string,
* class?: class-string,
* type?: '->'|'::',
* args?: mixed[],
* object?: object,
* } $trace
*/
private function outputTrace(array $trace): void
{
if (isset($trace['class'], $trace['type'], $trace['function'])) {
$this->output->writeln(\sprintf(
' <comment>%s</comment>%s<comment>%s()</comment>',
$this->prepareOutput($trace['class']),
$this->prepareOutput($trace['type']),
$this->prepareOutput($trace['function'])
));
} elseif (isset($trace['function'])) {
$this->output->writeln(\sprintf(' <comment>%s()</comment>', $this->prepareOutput($trace['function'])));
}
if (isset($trace['file'])) {
$this->output->writeln(\sprintf(' in <info>%s</info> at line <info>%d</info>', $this->prepareOutput($trace['file']), $trace['line']));
}
}
private function prepareOutput(string $string): string
{
return $this->isDecorated
? OutputFormatter::escape($string)
: $string;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Output;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @readonly
*
* @internal
*/
final class OutputContext
{
private ?OutputInterface $output;
private int $terminalWidth;
private int $filesCount;
public function __construct(
?OutputInterface $output,
int $terminalWidth,
int $filesCount
) {
$this->output = $output;
$this->terminalWidth = $terminalWidth;
$this->filesCount = $filesCount;
}
public function getOutput(): ?OutputInterface
{
return $this->output;
}
public function getTerminalWidth(): int
{
return $this->terminalWidth;
}
public function getFilesCount(): int
{
return $this->filesCount;
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Output\Progress;
use PhpCsFixer\Console\Output\OutputContext;
use PhpCsFixer\Runner\Event\FileProcessed;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Output writer to show the progress of a FixCommand using dots and meaningful letters.
*
* @internal
*/
final class DotsOutput implements ProgressOutputInterface
{
/**
* File statuses map.
*
* @var array<FileProcessed::STATUS_*, array{symbol: string, format: string, description: string}>
*/
private const EVENT_STATUS_MAP = [
FileProcessed::STATUS_NO_CHANGES => ['symbol' => '.', 'format' => '%s', 'description' => 'no changes'],
FileProcessed::STATUS_FIXED => ['symbol' => 'F', 'format' => '<fg=green>%s</fg=green>', 'description' => 'fixed'],
FileProcessed::STATUS_SKIPPED => ['symbol' => 'S', 'format' => '<fg=cyan>%s</fg=cyan>', 'description' => 'skipped (cached or empty file)'],
FileProcessed::STATUS_NON_MONOLITHIC => ['symbol' => 'M', 'format' => '<bg=magenta>%s</bg=magenta>', 'description' => 'skipped (non-monolithic)'],
FileProcessed::STATUS_INVALID => ['symbol' => 'I', 'format' => '<bg=red>%s</bg=red>', 'description' => 'invalid file syntax (file ignored)'],
FileProcessed::STATUS_EXCEPTION => ['symbol' => 'E', 'format' => '<bg=red>%s</bg=red>', 'description' => 'error'],
FileProcessed::STATUS_LINT => ['symbol' => 'E', 'format' => '<bg=red>%s</bg=red>', 'description' => 'error'],
];
/** @readonly */
private OutputContext $context;
private int $processedFiles = 0;
private int $symbolsPerLine;
public function __construct(OutputContext $context)
{
$this->context = $context;
// max number of characters per line
// - total length x 2 (e.g. " 1 / 123" => 6 digits and padding spaces)
// - 11 (extra spaces, parentheses and percentage characters, e.g. " x / x (100%)")
$this->symbolsPerLine = max(1, $context->getTerminalWidth() - \strlen((string) $context->getFilesCount()) * 2 - 11);
}
/**
* This class is not intended to be serialized,
* and cannot be deserialized (see __wakeup method).
*/
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.self::class);
}
/**
* Disable the deserialization of the class to prevent attacker executing
* code by leveraging the __destruct method.
*
* @see https://owasp.org/www-community/vulnerabilities/PHP_Object_Injection
*/
public function __wakeup(): void
{
throw new \BadMethodCallException('Cannot unserialize '.self::class);
}
public function onFixerFileProcessed(FileProcessed $event): void
{
$status = self::EVENT_STATUS_MAP[$event->getStatus()];
$this->getOutput()->write($this->getOutput()->isDecorated() ? \sprintf($status['format'], $status['symbol']) : $status['symbol']);
++$this->processedFiles;
$symbolsOnCurrentLine = $this->processedFiles % $this->symbolsPerLine;
$isLast = $this->processedFiles === $this->context->getFilesCount();
if (0 === $symbolsOnCurrentLine || $isLast) {
$this->getOutput()->write(\sprintf(
'%s %'.\strlen((string) $this->context->getFilesCount()).'d / %d (%3d%%)',
$isLast && 0 !== $symbolsOnCurrentLine ? str_repeat(' ', $this->symbolsPerLine - $symbolsOnCurrentLine) : '',
$this->processedFiles,
$this->context->getFilesCount(),
round($this->processedFiles / $this->context->getFilesCount() * 100)
));
if (!$isLast) {
$this->getOutput()->writeln('');
}
}
}
public function printLegend(): void
{
$symbols = [];
foreach (self::EVENT_STATUS_MAP as $status) {
$symbol = $status['symbol'];
if (isset($symbols[$symbol])) {
continue;
}
$symbols[$symbol] = \sprintf('%s-%s', $this->getOutput()->isDecorated() ? \sprintf($status['format'], $symbol) : $symbol, $status['description']);
}
$this->getOutput()->write(\sprintf("\nLegend: %s\n", implode(', ', $symbols)));
}
private function getOutput(): OutputInterface
{
return $this->context->getOutput();
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Output\Progress;
use PhpCsFixer\Runner\Event\FileProcessed;
/**
* @readonly
*
* @internal
*/
final class NullOutput implements ProgressOutputInterface
{
public function printLegend(): void {}
public function onFixerFileProcessed(FileProcessed $event): void {}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Output\Progress;
use PhpCsFixer\Console\Output\OutputContext;
use PhpCsFixer\Runner\Event\FileProcessed;
use Symfony\Component\Console\Helper\ProgressBar;
/**
* Output writer to show the progress of a FixCommand using progress bar (percentage).
*
* @readonly
*
* @internal
*/
final class PercentageBarOutput implements ProgressOutputInterface
{
/** @readonly */
private OutputContext $context;
private ProgressBar $progressBar;
public function __construct(OutputContext $context)
{
$this->context = $context;
$this->progressBar = new ProgressBar($context->getOutput(), $this->context->getFilesCount());
$this->progressBar->setBarCharacter('▓'); // dark shade character \u2593
$this->progressBar->setEmptyBarCharacter('░'); // light shade character \u2591
$this->progressBar->setProgressCharacter('');
$this->progressBar->setFormat('normal');
$this->progressBar->start();
}
/**
* This class is not intended to be serialized,
* and cannot be deserialized (see __wakeup method).
*/
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.self::class);
}
/**
* Disable the deserialization of the class to prevent attacker executing
* code by leveraging the __destruct method.
*
* @see https://owasp.org/www-community/vulnerabilities/PHP_Object_Injection
*/
public function __wakeup(): void
{
throw new \BadMethodCallException('Cannot unserialize '.self::class);
}
public function onFixerFileProcessed(FileProcessed $event): void
{
$this->progressBar->advance(1);
if ($this->progressBar->getProgress() === $this->progressBar->getMaxSteps()) {
$this->context->getOutput()->write("\n\n");
}
}
public function printLegend(): void {}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Output\Progress;
use PhpCsFixer\Console\Output\OutputContext;
/**
* @readonly
*
* @internal
*/
final class ProgressOutputFactory
{
/**
* @var array<ProgressOutputType::*, class-string<ProgressOutputInterface>>
*/
private const OUTPUT_TYPE_MAP = [
ProgressOutputType::NONE => NullOutput::class,
ProgressOutputType::DOTS => DotsOutput::class,
ProgressOutputType::BAR => PercentageBarOutput::class,
];
/**
* @param ProgressOutputType::* $outputType
*/
public function create(string $outputType, OutputContext $context): ProgressOutputInterface
{
if (null === $context->getOutput()) {
$outputType = ProgressOutputType::NONE;
}
if (!$this->isBuiltInType($outputType)) {
throw new \InvalidArgumentException(
\sprintf(
'Something went wrong, "%s" output type is not supported',
$outputType
)
);
}
$outputClass = self::OUTPUT_TYPE_MAP[$outputType];
// @phpstan-ignore-next-line new.noConstructor
return new $outputClass($context);
}
private function isBuiltInType(string $outputType): bool
{
return \in_array($outputType, ProgressOutputType::all(), true);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Output\Progress;
use PhpCsFixer\Runner\Event\FileProcessed;
/**
* @internal
*/
interface ProgressOutputInterface
{
public function printLegend(): void;
public function onFixerFileProcessed(FileProcessed $event): void;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Output\Progress;
/**
* @internal
*/
final class ProgressOutputType
{
public const NONE = 'none';
public const DOTS = 'dots';
public const BAR = 'bar';
/**
* @return non-empty-list<ProgressOutputType::*>
*/
public static function all(): array
{
return [
self::BAR,
self::DOTS,
self::NONE,
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
use PhpCsFixer\Console\Application;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* @author Kévin Gomez <contact@kevingomez.fr>
*
* @readonly
*
* @internal
*/
final class CheckstyleReporter implements ReporterInterface
{
public function getFormat(): string
{
return 'checkstyle';
}
public function generate(ReportSummary $reportSummary): string
{
if (!\extension_loaded('dom')) {
throw new \RuntimeException('Cannot generate report! `ext-dom` is not available!');
}
$dom = new \DOMDocument('1.0', 'UTF-8');
/** @var \DOMElement $checkstyles */
$checkstyles = $dom->appendChild($dom->createElement('checkstyle'));
$checkstyles->setAttribute('version', Application::getAbout());
foreach ($reportSummary->getChanged() as $filePath => $fixResult) {
/** @var \DOMElement $file */
$file = $checkstyles->appendChild($dom->createElement('file'));
$file->setAttribute('name', $filePath);
foreach ($fixResult['appliedFixers'] as $appliedFixer) {
$error = $this->createError($dom, $appliedFixer);
$file->appendChild($error);
}
}
$dom->formatOutput = true;
$result = $dom->saveXML();
if (false === $result) {
throw new \RuntimeException('Failed to generate XML output');
}
return $reportSummary->isDecoratedOutput() ? OutputFormatter::escape($result) : $result;
}
private function createError(\DOMDocument $dom, string $appliedFixer): \DOMElement
{
$error = $dom->createElement('error');
$error->setAttribute('severity', 'warning');
$error->setAttribute('source', 'PHP-CS-Fixer.'.$appliedFixer);
$error->setAttribute('message', 'Found violation(s) of type: '.$appliedFixer);
return $error;
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
use PhpCsFixer\Console\Application;
use SebastianBergmann\Diff\Chunk;
use SebastianBergmann\Diff\Diff;
use SebastianBergmann\Diff\Parser;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* Generates a report according to gitlabs subset of codeclimate json files.
*
* @author Hans-Christian Otto <c.otto@suora.com>
*
* @see https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md#data-types
*
* @readonly
*
* @internal
*/
final class GitlabReporter implements ReporterInterface
{
private Parser $diffParser;
public function __construct()
{
$this->diffParser = new Parser();
}
public function getFormat(): string
{
return 'gitlab';
}
/**
* Process changed files array. Returns generated report.
*/
public function generate(ReportSummary $reportSummary): string
{
$about = Application::getAbout();
$report = [];
foreach ($reportSummary->getChanged() as $fileName => $change) {
foreach ($change['appliedFixers'] as $fixerName) {
$report[] = [
'check_name' => 'PHP-CS-Fixer.'.$fixerName,
'description' => 'PHP-CS-Fixer.'.$fixerName.' by '.$about,
'categories' => ['Style'],
'fingerprint' => md5($fileName.$fixerName),
'severity' => 'minor',
'location' => [
'path' => $fileName,
'lines' => self::getLines($this->diffParser->parse($change['diff'])),
],
];
}
}
$jsonString = json_encode($report, \JSON_THROW_ON_ERROR);
return $reportSummary->isDecoratedOutput() ? OutputFormatter::escape($jsonString) : $jsonString;
}
/**
* @param list<Diff> $diffs
*
* @return array{begin: int, end: int}
*/
private static function getLines(array $diffs): array
{
if (isset($diffs[0])) {
$firstDiff = $diffs[0];
$firstChunk = \Closure::bind(static fn (Diff $diff) => array_shift($diff->chunks), null, $firstDiff)($firstDiff);
if ($firstChunk instanceof Chunk) {
return \Closure::bind(static fn (Chunk $chunk): array => ['begin' => $chunk->start, 'end' => $chunk->startRange], null, $firstChunk)($firstChunk);
}
}
return ['begin' => 0, 'end' => 0];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
use PhpCsFixer\Console\Application;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* @author Boris Gorbylev <ekho@ekho.name>
*
* @readonly
*
* @internal
*/
final class JsonReporter implements ReporterInterface
{
public function getFormat(): string
{
return 'json';
}
public function generate(ReportSummary $reportSummary): string
{
$jsonFiles = [];
foreach ($reportSummary->getChanged() as $file => $fixResult) {
$jsonFile = ['name' => $file];
if ($reportSummary->shouldAddAppliedFixers()) {
$jsonFile['appliedFixers'] = $fixResult['appliedFixers'];
}
if ('' !== $fixResult['diff']) {
$jsonFile['diff'] = $fixResult['diff'];
}
$jsonFiles[] = $jsonFile;
}
$json = [
'about' => Application::getAbout(),
'files' => $jsonFiles,
'time' => [
'total' => round($reportSummary->getTime() / 1_000, 3),
],
'memory' => round($reportSummary->getMemory() / 1_024 / 1_024, 3),
];
$json = json_encode($json, \JSON_THROW_ON_ERROR);
return $reportSummary->isDecoratedOutput() ? OutputFormatter::escape($json) : $json;
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
use PhpCsFixer\Console\Application;
use PhpCsFixer\Preg;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* @author Boris Gorbylev <ekho@ekho.name>
*
* @readonly
*
* @internal
*/
final class JunitReporter implements ReporterInterface
{
public function getFormat(): string
{
return 'junit';
}
public function generate(ReportSummary $reportSummary): string
{
if (!\extension_loaded('dom')) {
throw new \RuntimeException('Cannot generate report! `ext-dom` is not available!');
}
$dom = new \DOMDocument('1.0', 'UTF-8');
$testsuites = $dom->appendChild($dom->createElement('testsuites'));
/** @var \DOMElement $testsuite */
$testsuite = $testsuites->appendChild($dom->createElement('testsuite'));
$testsuite->setAttribute('name', 'PHP CS Fixer');
$properties = $dom->createElement('properties');
$property = $dom->createElement('property');
$property->setAttribute('name', 'about');
$property->setAttribute('value', Application::getAbout());
$properties->appendChild($property);
$testsuite->appendChild($properties);
if (\count($reportSummary->getChanged()) > 0) {
$this->createFailedTestCases($dom, $testsuite, $reportSummary);
} else {
$this->createSuccessTestCase($dom, $testsuite);
}
if ($reportSummary->getTime() > 0) {
$testsuite->setAttribute(
'time',
\sprintf(
'%.3f',
$reportSummary->getTime() / 1_000
)
);
}
$dom->formatOutput = true;
$result = $dom->saveXML();
if (false === $result) {
throw new \RuntimeException('Failed to generate XML output');
}
return $reportSummary->isDecoratedOutput() ? OutputFormatter::escape($result) : $result;
}
private function createSuccessTestCase(\DOMDocument $dom, \DOMElement $testsuite): void
{
$testcase = $dom->createElement('testcase');
$testcase->setAttribute('name', 'All OK');
$testcase->setAttribute('assertions', '1');
$testsuite->appendChild($testcase);
$testsuite->setAttribute('tests', '1');
$testsuite->setAttribute('assertions', '1');
$testsuite->setAttribute('failures', '0');
$testsuite->setAttribute('errors', '0');
}
private function createFailedTestCases(\DOMDocument $dom, \DOMElement $testsuite, ReportSummary $reportSummary): void
{
$assertionsCount = 0;
foreach ($reportSummary->getChanged() as $file => $fixResult) {
$testcase = $this->createFailedTestCase(
$dom,
$file,
$fixResult,
$reportSummary->shouldAddAppliedFixers()
);
$testsuite->appendChild($testcase);
$assertionsCount += (int) $testcase->getAttribute('assertions');
}
$testsuite->setAttribute('tests', (string) \count($reportSummary->getChanged()));
$testsuite->setAttribute('assertions', (string) $assertionsCount);
$testsuite->setAttribute('failures', (string) $assertionsCount);
$testsuite->setAttribute('errors', '0');
}
/**
* @param array{appliedFixers: list<string>, diff: string} $fixResult
*/
private function createFailedTestCase(\DOMDocument $dom, string $file, array $fixResult, bool $shouldAddAppliedFixers): \DOMElement
{
$appliedFixersCount = \count($fixResult['appliedFixers']);
$testName = str_replace('.', '_DOT_', Preg::replace('@\.'.pathinfo($file, \PATHINFO_EXTENSION).'$@', '', $file));
$testcase = $dom->createElement('testcase');
$testcase->setAttribute('name', $testName);
$testcase->setAttribute('file', $file);
$testcase->setAttribute('assertions', (string) $appliedFixersCount);
$failure = $dom->createElement('failure');
$failure->setAttribute('type', 'code_style');
$testcase->appendChild($failure);
if ($shouldAddAppliedFixers) {
$failureContent = "applied fixers:\n---------------\n";
foreach ($fixResult['appliedFixers'] as $appliedFixer) {
$failureContent .= "* {$appliedFixer}\n";
}
} else {
$failureContent = "Wrong code style\n";
}
if ('' !== $fixResult['diff']) {
$failureContent .= "\nDiff:\n---------------\n\n".$fixResult['diff'];
}
$failure->appendChild($dom->createCDATASection(trim($failureContent)));
return $testcase;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @readonly
*
* @internal
*/
final class ReportSummary
{
/**
* @var array<string, array{appliedFixers: list<string>, diff: string}>
*/
private array $changed;
private int $filesCount;
private int $time;
private int $memory;
private bool $addAppliedFixers;
private bool $isDryRun;
private bool $isDecoratedOutput;
/**
* @param array<string, array{appliedFixers: list<string>, diff: string}> $changed
* @param int $time duration in milliseconds
* @param int $memory memory usage in bytes
*/
public function __construct(
array $changed,
int $filesCount,
int $time,
int $memory,
bool $addAppliedFixers,
bool $isDryRun,
bool $isDecoratedOutput
) {
$this->changed = $changed;
$this->filesCount = $filesCount;
$this->time = $time;
$this->memory = $memory;
$this->addAppliedFixers = $addAppliedFixers;
$this->isDryRun = $isDryRun;
$this->isDecoratedOutput = $isDecoratedOutput;
}
public function isDecoratedOutput(): bool
{
return $this->isDecoratedOutput;
}
public function isDryRun(): bool
{
return $this->isDryRun;
}
/**
* @return array<string, array{appliedFixers: list<string>, diff: string}>
*/
public function getChanged(): array
{
return $this->changed;
}
public function getMemory(): int
{
return $this->memory;
}
public function getTime(): int
{
return $this->time;
}
public function getFilesCount(): int
{
return $this->filesCount;
}
public function shouldAddAppliedFixers(): bool
{
return $this->addAppliedFixers;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
use Symfony\Component\Finder\Finder as SymfonyFinder;
/**
* @author Boris Gorbylev <ekho@ekho.name>
*
* @internal
*/
final class ReporterFactory
{
/** @var array<string, ReporterInterface> */
private array $reporters = [];
public function registerBuiltInReporters(): self
{
/** @var null|list<string> $builtInReporters */
static $builtInReporters;
if (null === $builtInReporters) {
$builtInReporters = [];
foreach (SymfonyFinder::create()->files()->name('*Reporter.php')->in(__DIR__) as $file) {
$relativeNamespace = $file->getRelativePath();
$builtInReporters[] = \sprintf(
'%s\%s%s',
__NAMESPACE__,
'' !== $relativeNamespace ? $relativeNamespace.'\\' : '',
$file->getBasename('.php')
);
}
}
foreach ($builtInReporters as $reporterClass) {
$this->registerReporter(new $reporterClass());
}
return $this;
}
/**
* @return $this
*/
public function registerReporter(ReporterInterface $reporter): self
{
$format = $reporter->getFormat();
if (isset($this->reporters[$format])) {
throw new \UnexpectedValueException(\sprintf('Reporter for format "%s" is already registered.', $format));
}
$this->reporters[$format] = $reporter;
return $this;
}
/**
* @return list<string>
*/
public function getFormats(): array
{
$formats = array_keys($this->reporters);
sort($formats);
return $formats;
}
public function getReporter(string $format): ReporterInterface
{
if (!isset($this->reporters[$format])) {
throw new \UnexpectedValueException(\sprintf('Reporter for format "%s" is not registered.', $format));
}
return $this->reporters[$format];
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
/**
* @author Boris Gorbylev <ekho@ekho.name>
*
* @internal
*/
interface ReporterInterface
{
public function getFormat(): string;
/**
* Process changed files array. Returns generated report.
*/
public function generate(ReportSummary $reportSummary): string;
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
use PhpCsFixer\Differ\DiffConsoleFormatter;
/**
* @author Boris Gorbylev <ekho@ekho.name>
*
* @readonly
*
* @internal
*/
final class TextReporter implements ReporterInterface
{
public function getFormat(): string
{
return 'txt';
}
public function generate(ReportSummary $reportSummary): string
{
$output = '';
$identifiedFiles = 0;
foreach ($reportSummary->getChanged() as $file => $fixResult) {
++$identifiedFiles;
$output .= \sprintf('%4d) %s', $identifiedFiles, $file);
if ($reportSummary->shouldAddAppliedFixers()) {
$output .= $this->getAppliedFixers(
$reportSummary->isDecoratedOutput(),
$fixResult['appliedFixers'],
);
}
$output .= $this->getDiff($reportSummary->isDecoratedOutput(), $fixResult['diff']);
$output .= \PHP_EOL;
}
return $output.$this->getFooter(
$reportSummary->getTime(),
$identifiedFiles,
$reportSummary->getFilesCount(),
$reportSummary->getMemory(),
$reportSummary->isDryRun()
);
}
/**
* @param list<string> $appliedFixers
*/
private function getAppliedFixers(bool $isDecoratedOutput, array $appliedFixers): string
{
return \sprintf(
$isDecoratedOutput ? ' (<comment>%s</comment>)' : ' (%s)',
implode(', ', $appliedFixers)
);
}
private function getDiff(bool $isDecoratedOutput, string $diff): string
{
if ('' === $diff) {
return '';
}
$diffFormatter = new DiffConsoleFormatter($isDecoratedOutput, \sprintf(
'<comment> ---------- begin diff ----------</comment>%s%%s%s<comment> ----------- end diff -----------</comment>',
\PHP_EOL,
\PHP_EOL
));
return \PHP_EOL.$diffFormatter->format($diff).\PHP_EOL;
}
private function getFooter(int $time, int $identifiedFiles, int $files, int $memory, bool $isDryRun): string
{
if (0 === $time || 0 === $memory) {
return '';
}
return \PHP_EOL.\sprintf(
'%s %d of %d %s in %.3f seconds, %.2f MB memory used'.\PHP_EOL,
$isDryRun ? 'Found' : 'Fixed',
$identifiedFiles,
$files,
$isDryRun ? 'files that can be fixed' : 'files',
$time / 1_000,
$memory / 1_024 / 1_024
);
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\FixReport;
use PhpCsFixer\Console\Application;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* @author Boris Gorbylev <ekho@ekho.name>
*
* @readonly
*
* @internal
*/
final class XmlReporter implements ReporterInterface
{
public function getFormat(): string
{
return 'xml';
}
public function generate(ReportSummary $reportSummary): string
{
if (!\extension_loaded('dom')) {
throw new \RuntimeException('Cannot generate report! `ext-dom` is not available!');
}
$dom = new \DOMDocument('1.0', 'UTF-8');
// new nodes should be added to this or existing children
$root = $dom->createElement('report');
$dom->appendChild($root);
$root->appendChild($this->createAboutElement($dom, Application::getAbout()));
$filesXML = $dom->createElement('files');
$root->appendChild($filesXML);
$i = 1;
foreach ($reportSummary->getChanged() as $file => $fixResult) {
$fileXML = $dom->createElement('file');
$fileXML->setAttribute('id', (string) $i++);
$fileXML->setAttribute('name', $file);
$filesXML->appendChild($fileXML);
if ($reportSummary->shouldAddAppliedFixers()) {
$fileXML->appendChild(
$this->createAppliedFixersElement($dom, $fixResult['appliedFixers']),
);
}
if ('' !== $fixResult['diff']) {
$fileXML->appendChild($this->createDiffElement($dom, $fixResult['diff']));
}
}
if (0 !== $reportSummary->getTime()) {
$root->appendChild($this->createTimeElement($reportSummary->getTime(), $dom));
}
if (0 !== $reportSummary->getMemory()) {
$root->appendChild($this->createMemoryElement($reportSummary->getMemory(), $dom));
}
$dom->formatOutput = true;
$result = $dom->saveXML();
if (false === $result) {
throw new \RuntimeException('Failed to generate XML output');
}
return $reportSummary->isDecoratedOutput() ? OutputFormatter::escape($result) : $result;
}
/**
* @param list<string> $appliedFixers
*/
private function createAppliedFixersElement(\DOMDocument $dom, array $appliedFixers): \DOMElement
{
$appliedFixersXML = $dom->createElement('applied_fixers');
foreach ($appliedFixers as $appliedFixer) {
$appliedFixerXML = $dom->createElement('applied_fixer');
$appliedFixerXML->setAttribute('name', $appliedFixer);
$appliedFixersXML->appendChild($appliedFixerXML);
}
return $appliedFixersXML;
}
private function createDiffElement(\DOMDocument $dom, string $diff): \DOMElement
{
$diffXML = $dom->createElement('diff');
$diffXML->appendChild($dom->createCDATASection($diff));
return $diffXML;
}
private function createTimeElement(float $time, \DOMDocument $dom): \DOMElement
{
$time = round($time / 1_000, 3);
$timeXML = $dom->createElement('time');
$timeXML->setAttribute('unit', 's');
$timeTotalXML = $dom->createElement('total');
$timeTotalXML->setAttribute('value', (string) $time);
$timeXML->appendChild($timeTotalXML);
return $timeXML;
}
private function createMemoryElement(float $memory, \DOMDocument $dom): \DOMElement
{
$memory = round($memory / 1_024 / 1_024, 3);
$memoryXML = $dom->createElement('memory');
$memoryXML->setAttribute('value', (string) $memory);
$memoryXML->setAttribute('unit', 'MB');
return $memoryXML;
}
private function createAboutElement(\DOMDocument $dom, string $about): \DOMElement
{
$xml = $dom->createElement('about');
$xml->setAttribute('value', $about);
return $xml;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\ListSetsReport;
use PhpCsFixer\RuleSet\RuleSetDescriptionInterface;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @readonly
*
* @internal
*/
final class JsonReporter implements ReporterInterface
{
public function getFormat(): string
{
return 'json';
}
public function generate(ReportSummary $reportSummary): string
{
$sets = $reportSummary->getSets();
usort($sets, static fn (RuleSetDescriptionInterface $a, RuleSetDescriptionInterface $b): int => $a->getName() <=> $b->getName());
$json = ['sets' => []];
foreach ($sets as $set) {
$setName = $set->getName();
$json['sets'][$setName] = [
'description' => $set->getDescription(),
'isRisky' => $set->isRisky(),
'name' => $setName,
];
}
return json_encode($json, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\ListSetsReport;
use PhpCsFixer\RuleSet\RuleSetDescriptionInterface;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @readonly
*
* @internal
*/
final class ReportSummary
{
/**
* @var list<RuleSetDescriptionInterface>
*/
private array $sets;
/**
* @param list<RuleSetDescriptionInterface> $sets
*/
public function __construct(array $sets)
{
$this->sets = $sets;
}
/**
* @return list<RuleSetDescriptionInterface>
*/
public function getSets(): array
{
return $this->sets;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\ListSetsReport;
use Symfony\Component\Finder\Finder as SymfonyFinder;
/**
* @author Boris Gorbylev <ekho@ekho.name>
*
* @internal
*/
final class ReporterFactory
{
/**
* @var array<string, ReporterInterface>
*/
private array $reporters = [];
public function registerBuiltInReporters(): self
{
/** @var null|list<string> $builtInReporters */
static $builtInReporters;
if (null === $builtInReporters) {
$builtInReporters = [];
foreach (SymfonyFinder::create()->files()->name('*Reporter.php')->in(__DIR__) as $file) {
$relativeNamespace = $file->getRelativePath();
$builtInReporters[] = \sprintf(
'%s\%s%s',
__NAMESPACE__,
'' !== $relativeNamespace ? $relativeNamespace.'\\' : '',
$file->getBasename('.php')
);
}
}
foreach ($builtInReporters as $reporterClass) {
$this->registerReporter(new $reporterClass());
}
return $this;
}
public function registerReporter(ReporterInterface $reporter): self
{
$format = $reporter->getFormat();
if (isset($this->reporters[$format])) {
throw new \UnexpectedValueException(\sprintf('Reporter for format "%s" is already registered.', $format));
}
$this->reporters[$format] = $reporter;
return $this;
}
/**
* @return list<string>
*/
public function getFormats(): array
{
$formats = array_keys($this->reporters);
sort($formats);
return $formats;
}
public function getReporter(string $format): ReporterInterface
{
if (!isset($this->reporters[$format])) {
throw new \UnexpectedValueException(\sprintf('Reporter for format "%s" is not registered.', $format));
}
return $this->reporters[$format];
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\ListSetsReport;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
interface ReporterInterface
{
public function getFormat(): string;
/**
* Process changed files array. Returns generated report.
*/
public function generate(ReportSummary $reportSummary): string;
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\Report\ListSetsReport;
use PhpCsFixer\RuleSet\RuleSetDescriptionInterface;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @readonly
*
* @internal
*/
final class TextReporter implements ReporterInterface
{
public function getFormat(): string
{
return 'txt';
}
public function generate(ReportSummary $reportSummary): string
{
$sets = $reportSummary->getSets();
usort($sets, static fn (RuleSetDescriptionInterface $a, RuleSetDescriptionInterface $b): int => $a->getName() <=> $b->getName());
$output = '';
foreach ($sets as $i => $set) {
$output .= \sprintf('%2d) %s', $i + 1, $set->getName()).\PHP_EOL.' '.$set->getDescription().\PHP_EOL;
if ($set->isRisky()) {
$output .= ' Set contains risky rules.'.\PHP_EOL;
}
}
return $output;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\SelfUpdate;
/**
* @readonly
*
* @internal
*/
final class GithubClient implements GithubClientInterface
{
private string $url;
public function __construct(string $url = 'https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/tags')
{
$this->url = $url;
}
public function getTags(): array
{
$result = @file_get_contents(
$this->url,
false,
stream_context_create([
'http' => [
'header' => 'User-Agent: PHP-CS-Fixer/PHP-CS-Fixer',
],
])
);
if (false === $result) {
throw new \RuntimeException(\sprintf('Failed to load tags at "%s".', $this->url));
}
/**
* @var list<array{
* name: string,
* zipball_url: string,
* tarball_url: string,
* commit: array{sha: string, url: string},
* }>
*/
$result = json_decode($result, true);
if (\JSON_ERROR_NONE !== json_last_error()) {
throw new \RuntimeException(\sprintf(
'Failed to read response from "%s" as JSON: %s.',
$this->url,
json_last_error_msg()
));
}
return array_map(
static fn (array $tagData): string => $tagData['name'],
$result
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\SelfUpdate;
/**
* @internal
*/
interface GithubClientInterface
{
/**
* @return list<string>
*/
public function getTags(): array;
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\SelfUpdate;
use Composer\Semver\Comparator;
use Composer\Semver\Semver;
use Composer\Semver\VersionParser;
/**
* @internal
*/
final class NewVersionChecker implements NewVersionCheckerInterface
{
private GithubClientInterface $githubClient;
private VersionParser $versionParser;
/**
* @var null|list<string>
*/
private ?array $availableVersions = null;
public function __construct(GithubClientInterface $githubClient)
{
$this->githubClient = $githubClient;
$this->versionParser = new VersionParser();
}
public function getLatestVersion(): string
{
$this->retrieveAvailableVersions();
return $this->availableVersions[0];
}
public function getLatestVersionOfMajor(int $majorVersion): ?string
{
$this->retrieveAvailableVersions();
$semverConstraint = '^'.$majorVersion;
foreach ($this->availableVersions as $availableVersion) {
if (Semver::satisfies($availableVersion, $semverConstraint)) {
return $availableVersion;
}
}
return null;
}
public function compareVersions(string $versionA, string $versionB): int
{
$versionA = $this->versionParser->normalize($versionA);
$versionB = $this->versionParser->normalize($versionB);
if (Comparator::lessThan($versionA, $versionB)) {
return -1;
}
if (Comparator::greaterThan($versionA, $versionB)) {
return 1;
}
return 0;
}
private function retrieveAvailableVersions(): void
{
if (null !== $this->availableVersions) {
return;
}
foreach ($this->githubClient->getTags() as $version) {
try {
$this->versionParser->normalize($version);
if ('stable' === VersionParser::parseStability($version)) {
$this->availableVersions[] = $version;
}
} catch (\UnexpectedValueException $exception) {
// not a valid version tag
}
}
$versions = Semver::rsort($this->availableVersions);
\assert(array_is_list($versions)); // Semver::rsort provides soft `array` type, let's validate and ensure proper type for SCA
$this->availableVersions = $versions;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console\SelfUpdate;
/**
* @internal
*/
interface NewVersionCheckerInterface
{
/**
* Returns the tag of the latest version.
*/
public function getLatestVersion(): string;
/**
* Returns the tag of the latest minor/patch version of the given major version.
*/
public function getLatestVersionOfMajor(int $majorVersion): ?string;
/**
* Returns -1, 0, or 1 if the first version is respectively less than,
* equal to, or greater than the second.
*/
public function compareVersions(string $versionA, string $versionB): int;
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Console;
use PhpCsFixer\ToolInfo;
use PhpCsFixer\ToolInfoInterface;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class WarningsDetector
{
private ToolInfoInterface $toolInfo;
/**
* @var list<string>
*/
private array $warnings = [];
public function __construct(ToolInfoInterface $toolInfo)
{
$this->toolInfo = $toolInfo;
}
public function detectOldMajor(): void
{
// @TODO 3.99 to be activated with new MAJOR release 4.0
// $currentMajorVersion = \intval(explode('.', Application::VERSION)[0], 10);
// $nextMajorVersion = $currentMajorVersion + 1;
// $this->warnings[] = "You are running PHP CS Fixer v{$currentMajorVersion}, which is not maintained anymore. Please update to v{$nextMajorVersion}.";
// $this->warnings[] = "You may find an UPGRADE guide at https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/v{$nextMajorVersion}.0.0/UPGRADE-v{$nextMajorVersion}.md .";
}
public function detectOldVendor(): void
{
if ($this->toolInfo->isInstalledByComposer()) {
$details = $this->toolInfo->getComposerInstallationDetails();
if (ToolInfo::COMPOSER_LEGACY_PACKAGE_NAME === $details['name']) {
$this->warnings[] = \sprintf(
'You are running PHP CS Fixer installed with old vendor `%s`. Please update to `%s`.',
ToolInfo::COMPOSER_LEGACY_PACKAGE_NAME,
ToolInfo::COMPOSER_PACKAGE_NAME
);
}
}
}
public function detectNonMonolithic(): void
{
if (filter_var(getenv('PHP_CS_FIXER_NON_MONOLITHIC'), \FILTER_VALIDATE_BOOL)) {
$this->warnings[] = 'Processing non-monolithic files enabled, because `PHP_CS_FIXER_NON_MONOLITHIC` is set. Execution result may be unpredictable - non-monolithic files are not officially supported.';
}
}
/**
* @return list<string>
*/
public function getWarnings(): array
{
if (0 === \count($this->warnings)) {
return [];
}
return array_values(array_unique(array_merge(
$this->warnings,
['If you need help while solving warnings, ask at https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/discussions/, we will help you!']
)));
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Differ;
use PhpCsFixer\Preg;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @readonly
*
* @internal
*/
final class DiffConsoleFormatter
{
private bool $isDecoratedOutput;
private string $template;
public function __construct(bool $isDecoratedOutput, string $template = '%s')
{
$this->isDecoratedOutput = $isDecoratedOutput;
$this->template = $template;
}
public function format(string $diff, string $lineTemplate = '%s'): string
{
$isDecorated = $this->isDecoratedOutput;
$template = $isDecorated
? $this->template
: Preg::replace('/<[^<>]+>/', '', $this->template);
return \sprintf(
$template,
implode(
\PHP_EOL,
array_map(
static function (string $line) use ($isDecorated, $lineTemplate): string {
if ($isDecorated) {
$count = 0;
$line = Preg::replaceCallback(
'/^([+\-@].*)/',
static function (array $matches): string {
if ('+' === $matches[0][0]) {
$colour = 'green';
} elseif ('-' === $matches[0][0]) {
$colour = 'red';
} else {
$colour = 'cyan';
}
return \sprintf('<fg=%s>%s</fg=%s>', $colour, OutputFormatter::escape($matches[0]), $colour);
},
$line,
1,
$count
);
if (0 === $count) {
$line = OutputFormatter::escape($line);
}
}
return \sprintf($lineTemplate, $line);
},
Preg::split('#\R#u', $diff)
)
)
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Differ;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
interface DifferInterface
{
/**
* Create diff.
*/
public function diff(string $old, string $new, ?\SplFileInfo $file = null): string;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Differ;
use SebastianBergmann\Diff\Differ;
use SebastianBergmann\Diff\Output\StrictUnifiedDiffOutputBuilder;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @readonly
*
* @internal
*/
final class FullDiffer implements DifferInterface
{
private Differ $differ;
public function __construct()
{
$this->differ = new Differ(new StrictUnifiedDiffOutputBuilder([
'collapseRanges' => false,
'commonLineThreshold' => 100,
'contextLines' => 100,
'fromFile' => 'Original',
'toFile' => 'New',
]));
}
public function diff(string $old, string $new, ?\SplFileInfo $file = null): string
{
return $this->differ->diff($old, $new);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Differ;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
final class NullDiffer implements DifferInterface
{
public function diff(string $old, string $new, ?\SplFileInfo $file = null): string
{
return '';
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Differ;
use PhpCsFixer\Preg;
use SebastianBergmann\Diff\Differ;
use SebastianBergmann\Diff\Output\StrictUnifiedDiffOutputBuilder;
final class UnifiedDiffer implements DifferInterface
{
public function diff(string $old, string $new, ?\SplFileInfo $file = null): string
{
if (null === $file) {
$options = [
'fromFile' => 'Original',
'toFile' => 'New',
];
} else {
$filePath = $file->getRealPath();
if (Preg::match('/\s/', $filePath)) {
$filePath = '"'.$filePath.'"';
}
$options = [
'fromFile' => $filePath,
'toFile' => $filePath,
];
}
$differ = new Differ(new StrictUnifiedDiffOutputBuilder($options));
return $differ->diff($old, $new);
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;
/**
* This represents an entire annotation from a docblock.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
final class Annotation
{
/**
* All the annotation tag names with types.
*
* @var list<string>
*/
private const TAGS = [
'extends',
'implements',
'method',
'param',
'param-out',
'phpstan-type',
'phpstan-import-type',
'property',
'property-read',
'property-write',
'psalm-type',
'psalm-import-type',
'return',
'throws',
'type',
'var',
];
/**
* The lines that make up the annotation.
*
* @var non-empty-list<Line>
*/
private array $lines;
/**
* The position of the first line of the annotation in the docblock.
*/
private int $start;
/**
* The position of the last line of the annotation in the docblock.
*/
private int $end;
/**
* The associated tag.
*/
private ?Tag $tag = null;
/**
* Lazy loaded, cached types content.
*/
private ?string $typesContent = null;
/**
* The cached types.
*
* @var null|list<string>
*/
private ?array $types = null;
private ?NamespaceAnalysis $namespace = null;
/**
* @var list<NamespaceUseAnalysis>
*/
private array $namespaceUses;
/**
* Create a new line instance.
*
* @param non-empty-array<int, Line> $lines
* @param null|NamespaceAnalysis $namespace
* @param list<NamespaceUseAnalysis> $namespaceUses
*/
public function __construct(array $lines, $namespace = null, array $namespaceUses = [])
{
$this->lines = array_values($lines);
$this->namespace = $namespace;
$this->namespaceUses = $namespaceUses;
$this->start = array_key_first($lines);
$this->end = array_key_last($lines);
}
/**
* Get the string representation of object.
*/
public function __toString(): string
{
return $this->getContent();
}
/**
* Get all the annotation tag names with types.
*
* @return list<string>
*/
public static function getTagsWithTypes(): array
{
return self::TAGS;
}
/**
* Get the start position of this annotation.
*/
public function getStart(): int
{
return $this->start;
}
/**
* Get the end position of this annotation.
*/
public function getEnd(): int
{
return $this->end;
}
/**
* Get the associated tag.
*/
public function getTag(): Tag
{
if (null === $this->tag) {
$this->tag = new Tag($this->lines[0]);
}
return $this->tag;
}
/**
* @internal
*/
public function getTypeExpression(): ?TypeExpression
{
$typesContent = $this->getTypesContent();
return null === $typesContent
? null
: new TypeExpression($typesContent, $this->namespace, $this->namespaceUses);
}
/**
* @internal
*/
public function getVariableName(): ?string
{
$type = preg_quote($this->getTypesContent() ?? '', '/');
$regex = \sprintf(
'/@%s\s+(%s\s*)?(&\s*)?(\.{3}\s*)?(?<variable>\$%s)(?:.*|$)/',
$this->tag->getName(),
$type,
TypeExpression::REGEX_IDENTIFIER
);
if (Preg::match($regex, $this->getContent(), $matches)) {
\assert(isset($matches['variable']));
return $matches['variable'];
}
return null;
}
/**
* Get the types associated with this annotation.
*
* @return list<string>
*/
public function getTypes(): array
{
if (null === $this->types) {
$typeExpression = $this->getTypeExpression();
$this->types = null === $typeExpression
? []
: $typeExpression->getTypes();
}
return $this->types;
}
/**
* Set the types associated with this annotation.
*
* @param list<string> $types
*/
public function setTypes(array $types): void
{
$origTypesContent = $this->getTypesContent();
$newTypesContent = implode(
// Fallback to union type is provided for backward compatibility (previously glue was set to `|` by default even when type was not composite)
// @TODO Better handling for cases where type is fixed (original type is not composite, but was made composite during fix)
$this->getTypeExpression()->getTypesGlue() ?? '|',
$types
);
if ($origTypesContent === $newTypesContent) {
return;
}
$originalTypesLines = Preg::split('/([^\n\r]+\R*)/', $origTypesContent, -1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE);
$newTypesLines = Preg::split('/([^\n\r]+\R*)/', $newTypesContent, -1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE);
\assert(\count($originalTypesLines) === \count($newTypesLines));
foreach ($newTypesLines as $index => $line) {
\assert(isset($originalTypesLines[$index]));
$pattern = '/'.preg_quote($originalTypesLines[$index], '/').'/';
\assert(isset($this->lines[$index]));
$this->lines[$index]->setContent(Preg::replace($pattern, $line, $this->lines[$index]->getContent(), 1));
}
$this->clearCache();
}
/**
* Get the normalized types associated with this annotation, so they can easily be compared.
*
* @return list<string>
*/
public function getNormalizedTypes(): array
{
$typeExpression = $this->getTypeExpression();
if (null === $typeExpression) {
return [];
}
$normalizedTypeExpression = $typeExpression
->mapTypes(static fn (TypeExpression $v) => new TypeExpression(strtolower($v->toString()), null, []))
->sortTypes(static fn (TypeExpression $a, TypeExpression $b) => $a->toString() <=> $b->toString())
;
return $normalizedTypeExpression->getTypes();
}
/**
* Remove this annotation by removing all its lines.
*/
public function remove(): void
{
foreach ($this->lines as $line) {
if ($line->isTheStart() && $line->isTheEnd()) {
// Single line doc block, remove entirely
$line->remove();
} elseif ($line->isTheStart()) {
// Multi line doc block, but start is on the same line as the first annotation, keep only the start
$content = Preg::replace('#(\s*/\*\*).*#', '$1', $line->getContent());
$line->setContent($content);
} elseif ($line->isTheEnd()) {
// Multi line doc block, but end is on the same line as the last annotation, keep only the end
$content = Preg::replace('#(\s*)\S.*(\*/.*)#', '$1$2', $line->getContent());
$line->setContent($content);
} else {
// Multi line doc block, neither start nor end on this line, can be removed safely
$line->remove();
}
}
$this->clearCache();
}
/**
* Get the annotation content.
*/
public function getContent(): string
{
return implode('', $this->lines);
}
public function supportTypes(): bool
{
return \in_array($this->getTag()->getName(), self::TAGS, true);
}
/**
* Get the current types content.
*
* Be careful modifying the underlying line as that won't flush the cache.
*/
private function getTypesContent(): ?string
{
if (null === $this->typesContent) {
$name = $this->getTag()->getName();
if (!$this->supportTypes()) {
throw new \RuntimeException('This tag does not support types.');
}
if (Preg::match(
'{^(?:\h*\*|/\*\*)[\h*]*@'.$name.'\h+'.TypeExpression::REGEX_TYPES.'(?:(?:[*\h\v]|\&?[\.\$\s]).*)?\r?$}is',
$this->getContent(),
$matches
)) {
\assert(isset($matches['types']));
$this->typesContent = $matches['types'];
}
}
return $this->typesContent;
}
private function clearCache(): void
{
$this->types = null;
$this->typesContent = null;
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;
/**
* This class represents a docblock.
*
* It internally splits it up into "lines" that we can manipulate.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
*/
final class DocBlock
{
/**
* @var list<Line>
*/
private array $lines = [];
/**
* @var null|list<Annotation>
*/
private ?array $annotations = null;
private ?NamespaceAnalysis $namespace;
/**
* @var list<NamespaceUseAnalysis>
*/
private array $namespaceUses;
/**
* @param list<NamespaceUseAnalysis> $namespaceUses
*/
public function __construct(string $content, ?NamespaceAnalysis $namespace = null, array $namespaceUses = [])
{
foreach (Preg::split('/([^\n\r]+\R*)/', $content, -1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE) as $line) {
$this->lines[] = new Line($line);
}
$this->namespace = $namespace;
$this->namespaceUses = $namespaceUses;
}
public function __toString(): string
{
return $this->getContent();
}
/**
* Get this docblock's lines.
*
* @return list<Line>
*/
public function getLines(): array
{
return $this->lines;
}
/**
* Get a single line.
*/
public function getLine(int $pos): ?Line
{
return $this->lines[$pos] ?? null;
}
/**
* Get this docblock's annotations.
*
* @return list<Annotation>
*/
public function getAnnotations(): array
{
if (null !== $this->annotations) {
return $this->annotations;
}
$this->annotations = [];
$total = \count($this->lines);
for ($index = 0; $index < $total; ++$index) {
if ($this->lines[$index]->containsATag()) {
// get all the lines that make up the annotation
$lines = \array_slice($this->lines, $index, $this->findAnnotationLength($index), true);
\assert([] !== $lines);
$annotation = new Annotation($lines, $this->namespace, $this->namespaceUses);
// move the index to the end of the annotation to avoid
// checking it again because we know the lines inside the
// current annotation cannot be part of another annotation
$index = $annotation->getEnd();
// add the current annotation to the list of annotations
$this->annotations[] = $annotation;
}
}
return $this->annotations;
}
public function isMultiLine(): bool
{
return 1 !== \count($this->lines);
}
/**
* Take a one line doc block, and turn it into a multi line doc block.
*/
public function makeMultiLine(string $indent, string $lineEnd): void
{
if ($this->isMultiLine()) {
return;
}
$lineContent = $this->getSingleLineDocBlockEntry($this->lines[0]);
if ('' === $lineContent) {
$this->lines = [
new Line('/**'.$lineEnd),
new Line($indent.' *'.$lineEnd),
new Line($indent.' */'),
];
return;
}
$this->lines = [
new Line('/**'.$lineEnd),
new Line($indent.' * '.$lineContent.$lineEnd),
new Line($indent.' */'),
];
}
public function makeSingleLine(): void
{
if (!$this->isMultiLine()) {
return;
}
$usefulLines = array_filter(
$this->lines,
static fn (Line $line): bool => $line->containsUsefulContent()
);
if (1 < \count($usefulLines)) {
return;
}
$lineContent = '';
if (\count($usefulLines) > 0) {
$lineContent = $this->getSingleLineDocBlockEntry(array_shift($usefulLines));
}
$this->lines = [new Line('/** '.$lineContent.' */')];
}
public function getAnnotation(int $pos): ?Annotation
{
$annotations = $this->getAnnotations();
return $annotations[$pos] ?? null;
}
/**
* Get specific types of annotations only.
*
* @param list<string>|string $types
*
* @return list<Annotation>
*/
public function getAnnotationsOfType($types): array
{
$typesToSearchFor = (array) $types;
$annotations = [];
foreach ($this->getAnnotations() as $annotation) {
$tagName = $annotation->getTag()->getName();
if (\in_array($tagName, $typesToSearchFor, true)) {
$annotations[] = $annotation;
}
}
return $annotations;
}
/**
* Get the actual content of this docblock.
*/
public function getContent(): string
{
return implode('', $this->lines);
}
private function findAnnotationLength(int $start): int
{
$index = $start;
while (($line = $this->getLine(++$index)) !== null) {
if ($line->containsATag()) {
// we've 100% reached the end of the description if we get here
break;
}
if (!$line->containsUsefulContent()) {
// if next line is also non-useful, or contains a tag, then we're done here
$next = $this->getLine($index + 1);
if (null === $next || !$next->containsUsefulContent() || $next->containsATag()) {
break;
}
// otherwise, continue, the annotation must have contained a blank line in its description
}
}
return $index - $start;
}
private function getSingleLineDocBlockEntry(Line $line): string
{
$lineString = $line->getContent();
if ('' === $lineString) {
return $lineString;
}
$lineString = str_replace('*/', '', $lineString);
$lineString = trim($lineString);
if (str_starts_with($lineString, '/**')) {
$lineString = substr($lineString, 3);
} elseif (str_starts_with($lineString, '*')) {
$lineString = substr($lineString, 1);
}
return trim($lineString);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
/**
* This represents a line of a docblock.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
*/
final class Line
{
/**
* The content of this line.
*/
private string $content;
/**
* Create a new line instance.
*/
public function __construct(string $content)
{
$this->content = $content;
}
/**
* Get the string representation of object.
*/
public function __toString(): string
{
return $this->content;
}
/**
* Get the content of this line.
*/
public function getContent(): string
{
return $this->content;
}
/**
* Does this line contain useful content?
*
* If the line contains text or tags, then this is true.
*/
public function containsUsefulContent(): bool
{
return Preg::match('/\*\s*\S+/', $this->content) && '' !== trim(str_replace(['/', '*'], ' ', $this->content));
}
/**
* Does the line contain a tag?
*
* If this is true, then it must be the first line of an annotation.
*/
public function containsATag(): bool
{
return Preg::match('/\*\s*@/', $this->content);
}
/**
* Is the line the start of a docblock?
*/
public function isTheStart(): bool
{
return str_contains($this->content, '/**');
}
/**
* Is the line the end of a docblock?
*/
public function isTheEnd(): bool
{
return str_contains($this->content, '*/');
}
/**
* Set the content of this line.
*/
public function setContent(string $content): void
{
$this->content = $content;
}
/**
* Remove this line by clearing its contents.
*
* Note that this method technically brakes the internal state of the
* docblock, but is useful when we need to retain the indices of lines
* during the execution of an algorithm.
*/
public function remove(): void
{
$this->content = '';
}
/**
* Append a blank docblock line to this line's contents.
*
* Note that this method technically brakes the internal state of the
* docblock, but is useful when we need to retain the indices of lines
* during the execution of an algorithm.
*/
public function addBlank(): void
{
$matched = Preg::match('/^(\h*\*)[^\r\n]*(\r?\n)$/', $this->content, $matches);
if (!$matched) {
return;
}
$this->content .= $matches[1].$matches[2];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
/**
* This class represents a short description (aka summary) of a docblock.
*
* @readonly
*
* @internal
*/
final class ShortDescription
{
/**
* The docblock containing the short description.
*/
private DocBlock $doc;
public function __construct(DocBlock $doc)
{
$this->doc = $doc;
}
/**
* Get the line index of the line containing the end of the short
* description, if present.
*/
public function getEnd(): ?int
{
$reachedContent = false;
foreach ($this->doc->getLines() as $index => $line) {
// we went past a description, then hit a tag or blank line, so
// the last line of the description must be the one before this one
if ($reachedContent && ($line->containsATag() || !$line->containsUsefulContent())) {
return $index - 1;
}
// no short description was found
if ($line->containsATag()) {
return null;
}
// we've reached content, but need to check the next lines too
// in case the short description is multi-line
if ($line->containsUsefulContent()) {
$reachedContent = true;
}
}
return null;
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
/**
* This represents a tag, as defined by the proposed PSR PHPDoc standard.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
* @author Jakub Kwaśniewski <jakub@zero-85.pl>
*/
final class Tag
{
/**
* All the tags defined by the proposed PSR PHPDoc standard.
*/
public const PSR_STANDARD_TAGS = [
'api', 'author', 'category', 'copyright', 'deprecated', 'example',
'global', 'internal', 'license', 'link', 'method', 'package', 'param',
'property', 'property-read', 'property-write', 'return', 'see',
'since', 'subpackage', 'throws', 'todo', 'uses', 'var', 'version',
];
/**
* The line containing the tag.
*/
private Line $line;
/**
* The cached tag name.
*/
private ?string $name = null;
/**
* Create a new tag instance.
*/
public function __construct(Line $line)
{
$this->line = $line;
}
/**
* Get the tag name.
*
* This may be "param", or "return", etc.
*/
public function getName(): string
{
if (null === $this->name) {
Preg::matchAll('/@[a-zA-Z0-9_-]+(?=\s|$)/', $this->line->getContent(), $matches);
if (isset($matches[0][0])) {
$this->name = ltrim($matches[0][0], '@');
} else {
$this->name = 'other';
}
}
return $this->name;
}
/**
* Set the tag name.
*
* This will also be persisted to the upstream line and annotation.
*/
public function setName(string $name): void
{
$current = $this->getName();
if ('other' === $current) {
throw new \RuntimeException('Cannot set name on unknown tag.');
}
$this->line->setContent(Preg::replace("/@{$current}/", "@{$name}", $this->line->getContent(), 1));
$this->name = $name;
}
/**
* Is the tag a known tag?
*
* This is defined by if it exists in the proposed PSR PHPDoc standard.
*/
public function valid(): bool
{
return \in_array($this->getName(), self::PSR_STANDARD_TAGS, true);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
/**
* This class is responsible for comparing tags to see if they should be kept
* together, or kept apart.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
* @author Jakub Kwaśniewski <jakub@zero-85.pl>
*
* @deprecated
*/
final class TagComparator
{
/**
* Groups of tags that should be allowed to immediately follow each other.
*
* @var list<list<string>>
*
* @internal
*/
public const DEFAULT_GROUPS = [
['deprecated', 'link', 'see', 'since'],
['author', 'copyright', 'license'],
['category', 'package', 'subpackage'],
['property', 'property-read', 'property-write'],
];
/**
* Should the given tags be kept together, or kept apart?
*
* @param list<list<string>> $groups
*/
public static function shouldBeTogether(Tag $first, Tag $second, array $groups = self::DEFAULT_GROUPS): bool
{
@trigger_error('Method '.__METHOD__.' is deprecated and will be removed in version 4.0.', \E_USER_DEPRECATED);
$firstName = $first->getName();
$secondName = $second->getName();
if ($firstName === $secondName) {
return true;
}
foreach ($groups as $group) {
if (\in_array($firstName, $group, true) && \in_array($secondName, $group, true)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,857 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;
use PhpCsFixer\Utils;
/**
* @author Michael Vorisek <https://github.com/mvorisek>
*
* @internal
*/
final class TypeExpression
{
/**
* Regex to match any PHP identifier.
*
* @internal
*/
public const REGEX_IDENTIFIER = '(?:(?!(?<!\*)\d)[^\x00-\x2f\x3a-\x40\x5b-\x5e\x60\x7b-\x7f]++)';
/**
* Regex to match any PHPDoc type.
*
* @internal
*/
public const REGEX_TYPES = '(?<types>(?x) # one or several types separated by `|` or `&`
'.self::REGEX_TYPE.'
(?:
\h*(?<glue>[|&])\h*
(?&type)
)*+
)';
/**
* Based on:
* - https://github.com/phpstan/phpdoc-parser/blob/1.26.0/doc/grammars/type.abnf fuzzing grammar
* - and https://github.com/phpstan/phpdoc-parser/blob/1.26.0/src/Parser/PhpDocParser.php parser impl.
*/
private const REGEX_TYPE = '(?<type>(?x) # single type
(?<nullable>\??\h*)
(?:
(?<array_shape>
(?<array_shape_name>(?i)(?:array|list|object)(?-i))
(?<array_shape_start>\h*\{[\h\v*]*)
(?<array_shape_inners>
(?<array_shape_inner>
(?<array_shape_inner_key>(?:(?&constant)|(?&identifier)|(?&name))\h*\??\h*:\h*|)
(?<array_shape_inner_value>(?&types_inner))
)
(?:
\h*,[\h\v*]*
(?&array_shape_inner)
)*+
(?:\h*,|(?!(?&array_shape_unsealed_variadic)))
|)
(?<array_shape_unsealed> # unsealed array shape, e.g. `...`. `...<string>`
(?<array_shape_unsealed_variadic>\h*\.\.\.)
(?<array_shape_unsealed_type>
(?<array_shape_unsealed_type_start>\h*<\h*)
(?<array_shape_unsealed_type_a>(?&types_inner))
(?:
(?<array_shape_unsealed_type_comma>\h*,\h*)
(?<array_shape_unsealed_type_b>(?&array_shape_unsealed_type_a))
|)
\h*>
|)
|)
[\h\v*]*\}
)
|
(?<callable> # callable syntax, e.g. `callable(string, int...): bool`, `\Closure<T>(T, int): T`
(?<callable_name>(?&name))
(?<callable_template>
(?<callable_template_start>\h*<\h*)
(?<callable_template_inners>
(?<callable_template_inner>
(?<callable_template_inner_name>
(?&identifier)
)
(?<callable_template_inner_b> # template bound
\h+(?i)(?<callable_template_inner_b_kw>of|as)(?-i)\h+
(?<callable_template_inner_b_types>(?&types_inner))
|)
(?<callable_template_inner_d> # template default
\h*=\h*
(?<callable_template_inner_d_types>(?&types_inner))
|)
)
(?:
\h*,\h*
(?&callable_template_inner)
)*+
)
\h*>
(?=\h*\()
|)
(?<callable_start>\h*\(\h*)
(?<callable_arguments>
(?<callable_argument>
(?<callable_argument_type>(?&types_inner))
(?<callable_argument_is_reference>\h*&|)
(?<callable_argument_is_variadic>\h*\.\.\.|)
(?<callable_argument_name>\h*\$(?&identifier)|)
(?<callable_argument_is_optional>\h*=|)
)
(?:
\h*,\h*
(?&callable_argument)
)*+
(?:\h*,)?
|)
\h*\)
(?:
\h*\:\h*
(?<callable_return>(?&type))
)?
)
|
(?<generic> # generic syntax, e.g.: `array<int, \Foo\Bar>`
(?<generic_name>(?&name))
(?<generic_start>\h*<[\h\v*]*)
(?<generic_types>
(?&types_inner)
(?:
\h*,[\h\v*]*
(?&types_inner)
)*+
(?:\h*,)?
)
[\h\v*]*>
)
|
(?<class_constant> # class constants with optional wildcard, e.g.: `Foo::*`, `Foo::CONST_A`, `FOO::CONST_*`
(?<class_constant_name>(?&name))
::\*?(?:(?&identifier)\*?)*
)
|
(?<constant> # single constant value (case insensitive), e.g.: 1, -1.8E+6, `\'a\'`
(?i)
# all sorts of numbers: with or without sign, supports literal separator and several numeric systems,
# e.g.: 1, +1.1, 1., .1, -1, 123E+8, 123_456_789, 0x7Fb4, 0b0110, 0o777
[+-]?(?:
(?:0b[01]++(?:_[01]++)*+)
| (?:0o[0-7]++(?:_[0-7]++)*+)
| (?:0x[\da-f]++(?:_[\da-f]++)*+)
| (?:(?<constant_digits>\d++(?:_\d++)*+)|(?=\.\d))
(?:\.(?&constant_digits)|(?<=\d)\.)?+
(?:e[+-]?(?&constant_digits))?+
)
| \'(?:[^\'\\\]|\\\.)*+\'
| "(?:[^"\\\]|\\\.)*+"
(?-i)
)
|
(?<this> # self reference, e.g.: $this, $self, @static
(?i)
[@$](?:this | self | static)
(?-i)
)
|
(?<name> # full name, e.g.: `int`, `\DateTime`, `\Foo\Bar`, `positive-int`
\\\?+
(?<identifier>'.self::REGEX_IDENTIFIER.')
(?:[\\\\\-](?&identifier))*+
)
|
(?<parenthesized> # parenthesized type, e.g.: `(int)`, `(int|\stdClass)`
(?<parenthesized_start>
\(\h*
)
(?:
(?<parenthesized_types>
(?&types_inner)
)
|
(?<conditional> # conditional type, e.g.: `$foo is \Throwable ? false : $foo`
(?<conditional_cond_left>
(?:\$(?&identifier))
|
(?<conditional_cond_left_types>(?&types_inner))
)
(?<conditional_cond_middle>
\h+(?i)is(?:\h+not)?(?-i)\h+
)
(?<conditional_cond_right_types>(?&types_inner))
(?<conditional_true_start>\h*\?\h*)
(?<conditional_true_types>(?&types_inner))
(?<conditional_false_start>\h*:\h*)
(?<conditional_false_types>(?&types_inner))
)
)
\h*\)
)
)
(?<array> # array, e.g.: `string[]`, `array<int, string>[][]`
(\h*\[\h*\])*
)
(?:(?=1)0
(?<types_inner>(?>
(?&type)
(?:
\h*[|&]\h*
(?&type)
)*+
))
|)
)';
private string $value;
private bool $isCompositeType;
/** @var null|'&'|'|' */
private ?string $typesGlue = null;
/** @var list<array{start_index: int, expression: self}> */
private array $innerTypeExpressions = [];
private ?NamespaceAnalysis $namespace;
/** @var list<NamespaceUseAnalysis> */
private array $namespaceUses;
/**
* @param list<NamespaceUseAnalysis> $namespaceUses
*/
public function __construct(string $value, ?NamespaceAnalysis $namespace, array $namespaceUses)
{
$this->value = $value;
$this->namespace = $namespace;
$this->namespaceUses = $namespaceUses;
$this->parse();
}
public function toString(): string
{
return $this->value;
}
/**
* @return list<string>
*/
public function getTypes(): array
{
if ($this->isCompositeType) {
return array_map(
static fn (array $type) => $type['expression']->toString(),
$this->innerTypeExpressions,
);
}
return [$this->value];
}
/**
* Determines if type expression is a composite type (union or intersection).
*/
public function isCompositeType(): bool
{
return $this->isCompositeType;
}
public function isUnionType(): bool
{
return $this->isCompositeType && '|' === $this->typesGlue;
}
public function isIntersectionType(): bool
{
return $this->isCompositeType && '&' === $this->typesGlue;
}
/**
* @return null|'&'|'|'
*/
public function getTypesGlue(): ?string
{
return $this->typesGlue;
}
/**
* @param \Closure(self): self $callback
*/
public function mapTypes(\Closure $callback): self
{
$value = $this->value;
$startIndexOffset = 0;
foreach ($this->innerTypeExpressions as [
'start_index' => $startIndexOrig,
'expression' => $inner,
]) {
$innerValueOrig = $inner->value;
$inner = $inner->mapTypes($callback);
if ($inner->value !== $innerValueOrig) {
$value = substr_replace(
$value,
$inner->value,
$startIndexOrig + $startIndexOffset,
\strlen($innerValueOrig)
);
$startIndexOffset += \strlen($inner->value) - \strlen($innerValueOrig);
}
}
$type = $value === $this->value
? $this
: $this->inner($value);
return $callback($type);
}
/**
* @param \Closure(self): void $callback
*/
public function walkTypes(\Closure $callback): void
{
$this->mapTypes(static function (self $type) use ($callback) {
$valueOrig = $type->value;
$callback($type);
\assert($type->value === $valueOrig);
return $type;
});
}
/**
* @param \Closure(self, self): (-1|0|1) $compareCallback
*/
public function sortTypes(\Closure $compareCallback): self
{
return $this->mapTypes(function (self $type) use ($compareCallback): self {
if ($type->isCompositeType) {
$innerTypeExpressions = Utils::stableSort(
$type->innerTypeExpressions,
static fn (array $v): self => $v['expression'],
$compareCallback,
);
if ($innerTypeExpressions !== $type->innerTypeExpressions) {
$value = implode(
$type->getTypesGlue(),
array_map(static fn (array $v): string => $v['expression']->toString(), $innerTypeExpressions)
);
return $this->inner($value);
}
}
return $type;
});
}
public function getCommonType(): ?string
{
$aliases = $this->getAliases();
$mainType = null;
foreach ($this->getTypes() as $type) {
if ('null' === $type) {
continue;
}
if (str_starts_with($type, '?')) {
$type = substr($type, 1);
}
if (Preg::match('/\[\h*\]$/', $type)) {
$type = 'array';
} elseif (Preg::match('/^(.+?)\h*[<{(]/', $type, $matches)) {
$type = $matches[1];
}
if (isset($aliases[$type])) {
$type = $aliases[$type];
}
if (null === $mainType || $type === $mainType) {
$mainType = $type;
continue;
}
$mainType = $this->getParentType($type, $mainType);
if (null === $mainType) {
return null;
}
}
return $mainType;
}
public function allowsNull(): bool
{
foreach ($this->getTypes() as $type) {
if (\in_array($type, ['null', 'mixed'], true) || str_starts_with($type, '?')) {
return true;
}
}
return false;
}
private function parse(): void
{
$seenGlues = null;
$innerValues = [];
$index = 0;
while (true) {
Preg::match(
'{\G'.self::REGEX_TYPE.'(?<glue_raw>\h*(?<glue>[|&])\h*(?!$)|$)}',
$this->value,
$matches,
\PREG_OFFSET_CAPTURE,
$index
);
if ([] === $matches) {
throw new \Exception('Unable to parse phpdoc type '.var_export($this->value, true));
}
if (null === $seenGlues) {
if (($matches['glue'][0] ?? '') === '') {
break;
}
$seenGlues = ['|' => false, '&' => false];
}
if (($matches['glue'][0] ?? '') !== '') {
\assert(isset($seenGlues[$matches['glue'][0]]));
$seenGlues[$matches['glue'][0]] = true;
}
$innerValues[] = [
'start_index' => $index,
'value' => $matches['type'][0],
'next_glue' => $matches['glue'][0] ?? null,
'next_glue_raw' => $matches['glue_raw'][0] ?? null,
];
$consumedValueLength = \strlen($matches[0][0]);
$index += $consumedValueLength;
if (\strlen($this->value) <= $index) {
\assert(\strlen($this->value) === $index);
$seenGlues = array_filter($seenGlues);
\assert([] !== $seenGlues);
$this->isCompositeType = true;
$this->typesGlue = array_key_first($seenGlues);
if (1 === \count($seenGlues)) {
foreach ($innerValues as $innerValue) {
$this->innerTypeExpressions[] = [
'start_index' => $innerValue['start_index'],
'expression' => $this->inner($innerValue['value']),
];
}
} else {
for ($i = 0; $i < \count($innerValues); ++$i) {
$innerStartIndex = $innerValues[$i]['start_index'];
$innerValue = '';
while (true) {
$innerValue .= $innerValues[$i]['value'];
if (($innerValues[$i]['next_glue'] ?? $this->typesGlue) === $this->typesGlue) {
break;
}
$innerValue .= $innerValues[$i]['next_glue_raw'];
++$i;
}
$this->innerTypeExpressions[] = [
'start_index' => $innerStartIndex,
'expression' => $this->inner($innerValue),
];
}
}
return;
}
}
$this->isCompositeType = false;
if ('' !== $matches['nullable'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => \strlen($matches['nullable'][0]),
'expression' => $this->inner(substr($matches['type'][0], \strlen($matches['nullable'][0]))),
];
} elseif ('' !== $matches['array'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner(substr($matches['type'][0], 0, -\strlen($matches['array'][0]))),
];
} elseif ('' !== ($matches['generic'][0] ?? '') && 0 === $matches['generic'][1]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner($matches['generic_name'][0]),
];
$this->parseCommaSeparatedInnerTypes(
\strlen($matches['generic_name'][0]) + \strlen($matches['generic_start'][0]),
$matches['generic_types'][0]
);
} elseif ('' !== ($matches['callable'][0] ?? '') && 0 === $matches['callable'][1]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner($matches['callable_name'][0]),
];
$this->parseCallableTemplateInnerTypes(
\strlen($matches['callable_name'][0])
+ \strlen($matches['callable_template_start'][0]),
$matches['callable_template_inners'][0]
);
$this->parseCallableArgumentTypes(
\strlen($matches['callable_name'][0])
+ \strlen($matches['callable_template'][0])
+ \strlen($matches['callable_start'][0]),
$matches['callable_arguments'][0]
);
if ('' !== ($matches['callable_return'][0] ?? '')) {
$this->innerTypeExpressions[] = [
'start_index' => \strlen($this->value) - \strlen($matches['callable_return'][0]),
'expression' => $this->inner($matches['callable_return'][0]),
];
}
} elseif ('' !== ($matches['array_shape'][0] ?? '') && 0 === $matches['array_shape'][1]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner($matches['array_shape_name'][0]),
];
$nextIndex = \strlen($matches['array_shape_name'][0]) + \strlen($matches['array_shape_start'][0]);
$this->parseArrayShapeInnerTypes(
$nextIndex,
$matches['array_shape_inners'][0]
);
if ('' !== ($matches['array_shape_unsealed_type'][0] ?? '')) {
$nextIndex += \strlen($matches['array_shape_inners'][0])
+ \strlen($matches['array_shape_unsealed_variadic'][0])
+ \strlen($matches['array_shape_unsealed_type_start'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $nextIndex,
'expression' => $this->inner($matches['array_shape_unsealed_type_a'][0]),
];
if ('' !== ($matches['array_shape_unsealed_type_b'][0] ?? '')) {
$nextIndex += \strlen($matches['array_shape_unsealed_type_a'][0])
+ \strlen($matches['array_shape_unsealed_type_comma'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $nextIndex,
'expression' => $this->inner($matches['array_shape_unsealed_type_b'][0]),
];
}
}
} elseif ('' !== ($matches['parenthesized'][0] ?? '') && 0 === $matches['parenthesized'][1]) {
$index = \strlen($matches['parenthesized_start'][0]);
if ('' !== ($matches['conditional'][0] ?? '')) {
if ('' !== ($matches['conditional_cond_left_types'][0] ?? '')) {
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['conditional_cond_left_types'][0]),
];
}
$index += \strlen($matches['conditional_cond_left'][0]) + \strlen($matches['conditional_cond_middle'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['conditional_cond_right_types'][0]),
];
$index += \strlen($matches['conditional_cond_right_types'][0]) + \strlen($matches['conditional_true_start'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['conditional_true_types'][0]),
];
$index += \strlen($matches['conditional_true_types'][0]) + \strlen($matches['conditional_false_start'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['conditional_false_types'][0]),
];
} else {
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['parenthesized_types'][0]),
];
}
} elseif ('' !== $matches['class_constant'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner($matches['class_constant_name'][0]),
];
}
}
private function parseCommaSeparatedInnerTypes(int $startIndex, string $value): void
{
$index = 0;
while (\strlen($value) !== $index) {
Preg::match(
'{\G'.self::REGEX_TYPES.'(?:\h*,[\h\v*]*|$)}',
$value,
$matches,
0,
$index
);
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index,
'expression' => $this->inner($matches['types']),
];
$index += \strlen($matches[0]);
}
}
private function parseCallableTemplateInnerTypes(int $startIndex, string $value): void
{
$index = 0;
while (\strlen($value) !== $index) {
Preg::match(
'{\G(?:(?=1)0'.self::REGEX_TYPES.'|(?<_callable_template_inner>(?&callable_template_inner))(?:\h*,\h*|$))}',
$value,
$prematches,
0,
$index
);
$consumedValue = $prematches['_callable_template_inner'];
$consumedValueLength = \strlen($consumedValue);
$consumedCommaLength = \strlen($prematches[0]) - $consumedValueLength;
$addedPrefix = 'Closure<';
Preg::match(
'{^'.self::REGEX_TYPES.'$}',
$addedPrefix.$consumedValue.'>(): void',
$matches,
\PREG_OFFSET_CAPTURE
);
if ('' !== $matches['callable_template_inner_b'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index + $matches['callable_template_inner_b_types'][1]
- \strlen($addedPrefix),
'expression' => $this->inner($matches['callable_template_inner_b_types'][0]),
];
}
if ('' !== $matches['callable_template_inner_d'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index + $matches['callable_template_inner_d_types'][1]
- \strlen($addedPrefix),
'expression' => $this->inner($matches['callable_template_inner_d_types'][0]),
];
}
$index += $consumedValueLength + $consumedCommaLength;
}
}
private function parseCallableArgumentTypes(int $startIndex, string $value): void
{
$index = 0;
while (\strlen($value) !== $index) {
Preg::match(
'{\G(?:(?=1)0'.self::REGEX_TYPES.'|(?<_callable_argument>(?&callable_argument))(?:\h*,\h*|$))}',
$value,
$prematches,
0,
$index
);
$consumedValue = $prematches['_callable_argument'];
$consumedValueLength = \strlen($consumedValue);
$consumedCommaLength = \strlen($prematches[0]) - $consumedValueLength;
$addedPrefix = 'Closure(';
Preg::match(
'{^'.self::REGEX_TYPES.'$}',
$addedPrefix.$consumedValue.'): void',
$matches,
\PREG_OFFSET_CAPTURE
);
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index,
'expression' => $this->inner($matches['callable_argument_type'][0]),
];
$index += $consumedValueLength + $consumedCommaLength;
}
}
private function parseArrayShapeInnerTypes(int $startIndex, string $value): void
{
$index = 0;
while (\strlen($value) !== $index) {
Preg::match(
'{\G(?:(?=1)0'.self::REGEX_TYPES.'|(?<_array_shape_inner>(?&array_shape_inner))(?:\h*,[\h\v*]*|$))}',
$value,
$prematches,
0,
$index
);
$consumedValue = $prematches['_array_shape_inner'];
$consumedValueLength = \strlen($consumedValue);
$consumedCommaLength = \strlen($prematches[0]) - $consumedValueLength;
$addedPrefix = 'array{';
Preg::match(
'{^'.self::REGEX_TYPES.'$}',
$addedPrefix.$consumedValue.'}',
$matches,
\PREG_OFFSET_CAPTURE
);
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index + $matches['array_shape_inner_value'][1]
- \strlen($addedPrefix),
'expression' => $this->inner($matches['array_shape_inner_value'][0]),
];
$index += $consumedValueLength + $consumedCommaLength;
}
}
private function inner(string $value): self
{
return new self($value, $this->namespace, $this->namespaceUses);
}
private function getParentType(string $type1, string $type2): ?string
{
$types = [
$this->normalize($type1),
$this->normalize($type2),
];
natcasesort($types);
$types = implode('|', $types);
$parents = [
'array|Traversable' => 'iterable',
'array|iterable' => 'iterable',
'iterable|Traversable' => 'iterable',
'self|static' => 'self',
];
return $parents[$types] ?? null;
}
private function normalize(string $type): string
{
$aliases = $this->getAliases();
if (isset($aliases[$type])) {
return $aliases[$type];
}
if (\in_array($type, [
'array',
'bool',
'callable',
'false',
'float',
'int',
'iterable',
'mixed',
'never',
'null',
'object',
'resource',
'string',
'true',
'void',
], true)) {
return $type;
}
if (Preg::match('/\[\]$/', $type)) {
return 'array';
}
if (Preg::match('/^(.+?)</', $type, $matches)) {
return $matches[1];
}
if (str_starts_with($type, '\\')) {
return substr($type, 1);
}
foreach ($this->namespaceUses as $namespaceUse) {
if ($namespaceUse->getShortName() === $type) {
return $namespaceUse->getFullName();
}
}
if (null === $this->namespace || $this->namespace->isGlobalNamespace()) {
return $type;
}
return "{$this->namespace->getFullName()}\\{$type}";
}
/**
* @return array<string, string>
*/
private function getAliases(): array
{
return [
'boolean' => 'bool',
'callback' => 'callable',
'double' => 'float',
'false' => 'bool',
'integer' => 'int',
'list' => 'array',
'real' => 'float',
'true' => 'bool',
];
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Doctrine\Annotation;
use PhpCsFixer\Preg;
/**
* Copyright (c) 2006-2013 Doctrine Project.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* @internal
*/
final class DocLexer
{
public const T_NONE = 1;
public const T_INTEGER = 2;
public const T_STRING = 3;
public const T_FLOAT = 4;
// All tokens that are also identifiers should be >= 100
public const T_IDENTIFIER = 100;
public const T_AT = 101;
public const T_CLOSE_CURLY_BRACES = 102;
public const T_CLOSE_PARENTHESIS = 103;
public const T_COMMA = 104;
public const T_EQUALS = 105;
public const T_NAMESPACE_SEPARATOR = 107;
public const T_OPEN_CURLY_BRACES = 108;
public const T_OPEN_PARENTHESIS = 109;
public const T_COLON = 112;
public const T_MINUS = 113;
/** @var array<string, self::T_*> */
private array $noCase = [
'@' => self::T_AT,
',' => self::T_COMMA,
'(' => self::T_OPEN_PARENTHESIS,
')' => self::T_CLOSE_PARENTHESIS,
'{' => self::T_OPEN_CURLY_BRACES,
'}' => self::T_CLOSE_CURLY_BRACES,
'=' => self::T_EQUALS,
':' => self::T_COLON,
'-' => self::T_MINUS,
'\\' => self::T_NAMESPACE_SEPARATOR,
];
/** @var list<Token> */
private array $tokens = [];
private int $position = 0;
private int $peek = 0;
private ?string $regex = null;
public function setInput(string $input): void
{
$this->tokens = [];
$this->reset();
$this->scan($input);
}
public function reset(): void
{
$this->peek = 0;
$this->position = 0;
}
public function peek(): ?Token
{
if (isset($this->tokens[$this->position + $this->peek])) {
return $this->tokens[$this->position + $this->peek++];
}
return null;
}
/**
* @return list<string>
*/
private function getCatchablePatterns(): array
{
return [
'[a-z_\\\][a-z0-9_\:\\\]*[a-z_][a-z0-9_]*',
'(?:[+-]?[0-9]+(?:[\.][0-9]+)*)(?:[eE][+-]?[0-9]+)?',
'"(?:""|[^"])*+"',
];
}
/**
* @return list<string>
*/
private function getNonCatchablePatterns(): array
{
return ['\s+', '\*+', '(.)'];
}
/**
* @return self::T_*
*/
private function getType(string &$value): int
{
$type = self::T_NONE;
if ('"' === $value[0]) {
$value = str_replace('""', '"', substr($value, 1, \strlen($value) - 2));
return self::T_STRING;
}
if (isset($this->noCase[$value])) {
return $this->noCase[$value];
}
if ('_' === $value[0] || '\\' === $value[0] || !Preg::match('/[^A-Za-z]/', $value[0])) {
return self::T_IDENTIFIER;
}
if (is_numeric($value)) {
return str_contains($value, '.') || false !== stripos($value, 'e')
? self::T_FLOAT : self::T_INTEGER;
}
return $type;
}
private function scan(string $input): void
{
$this->regex ??= \sprintf(
'/(%s)|%s/%s',
implode(')|(', $this->getCatchablePatterns()),
implode('|', $this->getNonCatchablePatterns()),
'iu'
);
$flags = \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_OFFSET_CAPTURE;
$matches = Preg::split($this->regex, $input, -1, $flags);
foreach ($matches as $match) {
// Must remain before 'value' assignment since it can change content
$firstMatch = $match[0];
$type = $this->getType($firstMatch);
$this->tokens[] = new Token($type, $firstMatch, (int) $match[1]);
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Doctrine\Annotation;
/**
* A Doctrine annotation token.
*
* @internal
*/
final class Token
{
private int $type;
private string $content;
private int $position;
/**
* @param int $type The type
* @param string $content The content
*/
public function __construct(int $type = DocLexer::T_NONE, string $content = '', int $position = 0)
{
$this->type = $type;
$this->content = $content;
$this->position = $position;
}
public function getType(): int
{
return $this->type;
}
public function setType(int $type): void
{
$this->type = $type;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
}
public function getPosition(): int
{
return $this->position;
}
/**
* Returns whether the token type is one of the given types.
*
* @param int|list<int> $types
*/
public function isType($types): bool
{
if (!\is_array($types)) {
$types = [$types];
}
return \in_array($this->getType(), $types, true);
}
/**
* Overrides the content with an empty string.
*/
public function clear(): void
{
$this->setContent('');
}
}

View File

@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Doctrine\Annotation;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Token as PhpToken;
/**
* A list of Doctrine annotation tokens.
*
* @internal
*
* @extends \SplFixedArray<Token>
*/
final class Tokens extends \SplFixedArray
{
/**
* @param list<string> $ignoredTags
*
* @throws \InvalidArgumentException
*/
public static function createFromDocComment(PhpToken $input, array $ignoredTags = []): self
{
if (!$input->isGivenKind(\T_DOC_COMMENT)) {
throw new \InvalidArgumentException('Input must be a T_DOC_COMMENT token.');
}
$tokens = [];
$content = $input->getContent();
$ignoredTextPosition = 0;
$currentPosition = 0;
$token = null;
while (false !== $nextAtPosition = strpos($content, '@', $currentPosition)) {
if (0 !== $nextAtPosition && !Preg::match('/\s/', $content[$nextAtPosition - 1])) {
$currentPosition = $nextAtPosition + 1;
continue;
}
$lexer = new DocLexer();
$lexer->setInput(substr($content, $nextAtPosition));
$scannedTokens = [];
$index = 0;
$nbScannedTokensToUse = 0;
$nbScopes = 0;
while (null !== $token = $lexer->peek()) {
if (0 === $index && !$token->isType(DocLexer::T_AT)) {
break;
}
if (1 === $index) {
if (!$token->isType(DocLexer::T_IDENTIFIER) || \in_array($token->getContent(), $ignoredTags, true)) {
break;
}
$nbScannedTokensToUse = 2;
}
if ($index >= 2 && 0 === $nbScopes && !$token->isType([DocLexer::T_NONE, DocLexer::T_OPEN_PARENTHESIS])) {
break;
}
$scannedTokens[] = $token;
if ($token->isType(DocLexer::T_OPEN_PARENTHESIS)) {
++$nbScopes;
} elseif ($token->isType(DocLexer::T_CLOSE_PARENTHESIS)) {
if (0 === --$nbScopes) {
$nbScannedTokensToUse = \count($scannedTokens);
break;
}
}
++$index;
}
if (0 !== $nbScopes) {
break;
}
if (0 !== $nbScannedTokensToUse) {
$ignoredTextLength = $nextAtPosition - $ignoredTextPosition;
if (0 !== $ignoredTextLength) {
$tokens[] = new Token(DocLexer::T_NONE, substr($content, $ignoredTextPosition, $ignoredTextLength));
}
$lastTokenEndIndex = 0;
foreach (\array_slice($scannedTokens, 0, $nbScannedTokensToUse) as $scannedToken) {
$token = $scannedToken->isType(DocLexer::T_STRING)
? new Token(
$scannedToken->getType(),
'"'.str_replace('"', '""', $scannedToken->getContent()).'"',
$scannedToken->getPosition()
)
: $scannedToken;
$missingTextLength = $token->getPosition() - $lastTokenEndIndex;
if ($missingTextLength > 0) {
$tokens[] = new Token(DocLexer::T_NONE, substr(
$content,
$nextAtPosition + $lastTokenEndIndex,
$missingTextLength
));
}
$tokens[] = new Token($token->getType(), $token->getContent());
$lastTokenEndIndex = $token->getPosition() + \strlen($token->getContent());
}
$currentPosition = $ignoredTextPosition = $nextAtPosition + $token->getPosition() + \strlen($token->getContent());
} else {
$currentPosition = $nextAtPosition + 1;
}
}
if ($ignoredTextPosition < \strlen($content)) {
$tokens[] = new Token(DocLexer::T_NONE, substr($content, $ignoredTextPosition));
}
return self::fromArray($tokens);
}
/**
* Create token collection from array.
*
* @param array<int, Token> $array the array to import
* @param ?bool $saveIndices save the numeric indices used in the original array, default is yes
*/
public static function fromArray($array, $saveIndices = null): self
{
$tokens = new self(\count($array));
if (null === $saveIndices || $saveIndices) {
foreach ($array as $key => $val) {
$tokens[$key] = $val;
}
} else {
$index = 0;
foreach ($array as $val) {
$tokens[$index++] = $val;
}
}
return $tokens;
}
/**
* Returns the index of the closest next token that is neither a comment nor a whitespace token.
*/
public function getNextMeaningfulToken(int $index): ?int
{
return $this->getMeaningfulTokenSibling($index, 1);
}
/**
* Returns the index of the last token that is part of the annotation at the given index.
*/
public function getAnnotationEnd(int $index): ?int
{
$currentIndex = null;
if (isset($this[$index + 2])) {
if ($this[$index + 2]->isType(DocLexer::T_OPEN_PARENTHESIS)) {
$currentIndex = $index + 2;
} elseif (
isset($this[$index + 3])
&& $this[$index + 2]->isType(DocLexer::T_NONE)
&& $this[$index + 3]->isType(DocLexer::T_OPEN_PARENTHESIS)
&& Preg::match('/^(\R\s*\*\s*)*\s*$/', $this[$index + 2]->getContent())
) {
$currentIndex = $index + 3;
}
}
if (null !== $currentIndex) {
$level = 0;
for ($max = \count($this); $currentIndex < $max; ++$currentIndex) {
if ($this[$currentIndex]->isType(DocLexer::T_OPEN_PARENTHESIS)) {
++$level;
} elseif ($this[$currentIndex]->isType(DocLexer::T_CLOSE_PARENTHESIS)) {
--$level;
}
if (0 === $level) {
return $currentIndex;
}
}
return null;
}
return $index + 1;
}
/**
* Returns the code from the tokens.
*/
public function getCode(): string
{
$code = '';
foreach ($this as $token) {
$code .= $token->getContent();
}
return $code;
}
/**
* Inserts a token at the given index.
*/
public function insertAt(int $index, Token $token): void
{
$this->setSize($this->getSize() + 1);
for ($i = $this->getSize() - 1; $i > $index; --$i) {
$this[$i] = $this[$i - 1] ?? new Token();
}
$this[$index] = $token;
}
public function offsetSet($index, $token): void
{
if (null === $token) {
throw new \InvalidArgumentException('Token must be an instance of PhpCsFixer\Doctrine\Annotation\Token, "null" given.');
}
if (!$token instanceof Token) {
$type = \gettype($token);
if ('object' === $type) {
$type = \get_class($token);
}
throw new \InvalidArgumentException(\sprintf('Token must be an instance of PhpCsFixer\Doctrine\Annotation\Token, "%s" given.', $type));
}
parent::offsetSet($index, $token);
}
/**
* @param mixed $index
*
* @throws \OutOfBoundsException
*/
public function offsetUnset($index): void
{
if (!isset($this[$index])) {
throw new \OutOfBoundsException(\sprintf('Index "%s" is invalid or does not exist.', $index));
}
$max = \count($this) - 1;
while ($index < $max) {
$this[$index] = $this[$index + 1];
++$index;
}
parent::offsetUnset($index);
$this->setSize($max);
}
private function getMeaningfulTokenSibling(int $index, int $direction): ?int
{
while (true) {
$index += $direction;
if (!$this->offsetExists($index)) {
break;
}
if (!$this[$index]->isType(DocLexer::T_NONE)) {
return $index;
}
}
return null;
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Documentation;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\Utils;
/**
* @readonly
*
* @internal
*/
final class DocumentationLocator
{
private string $path;
public function __construct()
{
$this->path = \dirname(__DIR__, 2).'/doc';
}
public function getFixersDocumentationDirectoryPath(): string
{
return $this->path.'/rules';
}
public function getFixersDocumentationIndexFilePath(): string
{
return $this->getFixersDocumentationDirectoryPath().'/index.rst';
}
public function getFixerDocumentationFilePath(FixerInterface $fixer): string
{
return $this->getFixersDocumentationDirectoryPath().'/'.Preg::replaceCallback(
'/^.*\\\(.+)\\\(.+)Fixer$/',
static fn (array $matches): string => Utils::camelCaseToUnderscore($matches[1]).'/'.Utils::camelCaseToUnderscore($matches[2]),
\get_class($fixer)
).'.rst';
}
public function getFixerDocumentationFileRelativePath(FixerInterface $fixer): string
{
return Preg::replace(
'#^'.preg_quote($this->getFixersDocumentationDirectoryPath(), '#').'/#',
'',
$this->getFixerDocumentationFilePath($fixer)
);
}
public function getRuleSetsDocumentationDirectoryPath(): string
{
return $this->path.'/ruleSets';
}
public function getRuleSetsDocumentationIndexFilePath(): string
{
return $this->getRuleSetsDocumentationDirectoryPath().'/index.rst';
}
public function getRuleSetsDocumentationFilePath(string $name): string
{
return $this->getRuleSetsDocumentationDirectoryPath().'/'.str_replace(':risky', 'Risky', ucfirst(substr($name, 1))).'.rst';
}
public function getUsageFilePath(): string
{
return $this->path.'/usage.rst';
}
}

View File

@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Documentation;
use PhpCsFixer\Console\Command\HelpCommand;
use PhpCsFixer\Differ\FullDiffer;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\DeprecatedFixerInterface;
use PhpCsFixer\Fixer\ExperimentalFixerInterface;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\FixerConfiguration\AliasedFixerOption;
use PhpCsFixer\FixerConfiguration\AllowedValueSubset;
use PhpCsFixer\FixerConfiguration\DeprecatedFixerOptionInterface;
use PhpCsFixer\FixerDefinition\CodeSampleInterface;
use PhpCsFixer\FixerDefinition\FileSpecificCodeSampleInterface;
use PhpCsFixer\FixerDefinition\VersionSpecificCodeSampleInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\RuleSet\RuleSet;
use PhpCsFixer\RuleSet\RuleSets;
use PhpCsFixer\StdinFileInfo;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixer\Utils;
/**
* @readonly
*
* @internal
*/
final class FixerDocumentGenerator
{
private DocumentationLocator $locator;
private FullDiffer $differ;
public function __construct(DocumentationLocator $locator)
{
$this->locator = $locator;
$this->differ = new FullDiffer();
}
public function generateFixerDocumentation(FixerInterface $fixer): string
{
$name = $fixer->getName();
$title = "Rule ``{$name}``";
$titleLine = str_repeat('=', \strlen($title));
$doc = "{$titleLine}\n{$title}\n{$titleLine}";
$definition = $fixer->getDefinition();
$doc .= "\n\n".RstUtils::toRst($definition->getSummary());
$description = $definition->getDescription();
if (null !== $description) {
$description = RstUtils::toRst($description);
$doc .= <<<RST
Description
-----------
{$description}
RST;
}
$deprecationDescription = '';
if ($fixer instanceof DeprecatedFixerInterface) {
$deprecationDescription = <<<'RST'
This rule is deprecated and will be removed in the next major version
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
RST;
$alternatives = $fixer->getSuccessorsNames();
if (0 !== \count($alternatives)) {
$deprecationDescription .= RstUtils::toRst(\sprintf(
"\n\nYou should use %s instead.",
Utils::naturalLanguageJoinWithBackticks($alternatives)
), 0);
}
}
$experimentalDescription = '';
if ($fixer instanceof ExperimentalFixerInterface) {
$experimentalDescriptionRaw = RstUtils::toRst('Rule is not covered with backward compatibility promise, use it at your own risk. Rule\'s behaviour may be changed at any point, including rule\'s name; its options\' names, availability and allowed values; its default configuration. Rule may be even removed without prior notice. Feel free to provide feedback and help with determining final state of the rule.', 0);
$experimentalDescription = <<<RST
This rule is experimental
~~~~~~~~~~~~~~~~~~~~~~~~~
{$experimentalDescriptionRaw}
RST;
}
$riskyDescription = '';
$riskyDescriptionRaw = $definition->getRiskyDescription();
if (null !== $riskyDescriptionRaw) {
$riskyDescriptionRaw = RstUtils::toRst($riskyDescriptionRaw, 0);
$riskyDescription = <<<RST
Using this rule is risky
~~~~~~~~~~~~~~~~~~~~~~~~
{$riskyDescriptionRaw}
RST;
}
if ('' !== $deprecationDescription || '' !== $riskyDescription) {
$warningsHeader = 'Warning';
if ('' !== $deprecationDescription && '' !== $riskyDescription) {
$warningsHeader = 'Warnings';
}
$warningsHeaderLine = str_repeat('-', \strlen($warningsHeader));
$doc .= "\n\n".implode("\n", array_filter(
[
$warningsHeader,
$warningsHeaderLine,
$deprecationDescription,
$experimentalDescription,
$riskyDescription,
],
static fn (string $text): bool => '' !== $text
));
}
if ($fixer instanceof ConfigurableFixerInterface) {
$doc .= <<<'RST'
Configuration
-------------
RST;
$configurationDefinition = $fixer->getConfigurationDefinition();
foreach ($configurationDefinition->getOptions() as $option) {
$optionInfo = "``{$option->getName()}``";
$optionInfo .= "\n".str_repeat('~', \strlen($optionInfo));
if ($option instanceof DeprecatedFixerOptionInterface) {
$deprecationMessage = RstUtils::toRst($option->getDeprecationMessage());
$optionInfo .= "\n\n.. warning:: This option is deprecated and will be removed in the next major version. {$deprecationMessage}";
}
$optionInfo .= "\n\n".RstUtils::toRst($option->getDescription());
if ($option instanceof AliasedFixerOption) {
$optionInfo .= "\n\n.. note:: The previous name of this option was ``{$option->getAlias()}`` but it is now deprecated and will be removed in the next major version.";
}
$allowed = HelpCommand::getDisplayableAllowedValues($option);
if (null === $allowed) {
$allowedKind = 'Allowed types';
$allowed = array_map(
static fn (string $value): string => '``'.Utils::convertArrayTypeToList($value).'``',
$option->getAllowedTypes(),
);
} else {
$allowedKind = 'Allowed values';
$allowed = array_map(static fn ($value): string => $value instanceof AllowedValueSubset
? 'a subset of ``'.Utils::toString($value->getAllowedValues()).'``'
: '``'.Utils::toString($value).'``', $allowed);
}
$allowed = Utils::naturalLanguageJoin($allowed, '');
$optionInfo .= "\n\n{$allowedKind}: {$allowed}";
if ($option->hasDefault()) {
$default = Utils::toString($option->getDefault());
$optionInfo .= "\n\nDefault value: ``{$default}``";
} else {
$optionInfo .= "\n\nThis option is required.";
}
$doc .= "\n\n{$optionInfo}";
}
}
$samples = $definition->getCodeSamples();
if (0 !== \count($samples)) {
$doc .= <<<'RST'
Examples
--------
RST;
foreach ($samples as $index => $sample) {
$title = \sprintf('Example #%d', $index + 1);
$titleLine = str_repeat('~', \strlen($title));
$doc .= "\n\n{$title}\n{$titleLine}";
if ($fixer instanceof ConfigurableFixerInterface) {
if (null === $sample->getConfiguration()) {
$doc .= "\n\n*Default* configuration.";
} else {
$doc .= \sprintf(
"\n\nWith configuration: ``%s``.",
Utils::toString($sample->getConfiguration())
);
}
}
$doc .= "\n".$this->generateSampleDiff($fixer, $sample, $index + 1, $name);
}
}
$ruleSetConfigs = self::getSetsOfRule($name);
if ([] !== $ruleSetConfigs) {
$plural = 1 !== \count($ruleSetConfigs) ? 's' : '';
$doc .= <<<RST
Rule sets
---------
The rule is part of the following rule set{$plural}:\n\n
RST;
foreach ($ruleSetConfigs as $set => $config) {
$ruleSetPath = $this->locator->getRuleSetsDocumentationFilePath($set);
$ruleSetPath = substr($ruleSetPath, strrpos($ruleSetPath, '/'));
$configInfo = (null !== $config)
? " with config:\n\n ``".Utils::toString($config)."``\n"
: '';
$doc .= <<<RST
- `{$set} <./../../ruleSets{$ruleSetPath}>`_{$configInfo}\n
RST;
}
$doc = trim($doc);
}
$reflectionObject = new \ReflectionObject($fixer);
$className = str_replace('\\', '\\\\', $reflectionObject->getName());
$fileName = $reflectionObject->getFileName();
$fileName = str_replace('\\', '/', $fileName);
$fileName = substr($fileName, strrpos($fileName, '/src/Fixer/') + 1);
$fileName = "`{$className} <./../../../{$fileName}>`_";
$testFileName = Preg::replace('~.*\K/src/(?=Fixer/)~', '/tests/', $fileName);
$testFileName = Preg::replace('~PhpCsFixer\\\\\\\\\K(?=Fixer\\\\\\\)~', 'Tests\\\\\\\\', $testFileName);
$testFileName = Preg::replace('~(?= <|\.php>)~', 'Test', $testFileName);
$doc .= <<<RST
References
----------
- Fixer class: {$fileName}
- Test class: {$testFileName}
The test class defines officially supported behaviour. Each test case is a part of our backward compatibility promise.
RST;
$doc = str_replace("\t", '<TAB>', $doc);
return "{$doc}\n";
}
/**
* @internal
*
* @return array<string, null|array<string, mixed>>
*/
public static function getSetsOfRule(string $ruleName): array
{
$ruleSetConfigs = [];
foreach (RuleSets::getSetDefinitionNames() as $set) {
$ruleSet = new RuleSet([$set => true]);
if ($ruleSet->hasRule($ruleName)) {
$ruleSetConfigs[$set] = $ruleSet->getRuleConfiguration($ruleName);
}
}
return $ruleSetConfigs;
}
/**
* @param list<FixerInterface> $fixers
*/
public function generateFixersDocumentationIndex(array $fixers): string
{
$overrideGroups = [
'PhpUnit' => 'PHPUnit',
'PhpTag' => 'PHP Tag',
'Phpdoc' => 'PHPDoc',
];
usort($fixers, static fn (FixerInterface $a, FixerInterface $b): int => \get_class($a) <=> \get_class($b));
$documentation = <<<'RST'
=======================
List of Available Rules
=======================
RST;
$currentGroup = null;
foreach ($fixers as $fixer) {
$namespace = Preg::replace('/^.*\\\(.+)\\\.+Fixer$/', '$1', \get_class($fixer));
$group = $overrideGroups[$namespace] ?? Preg::replace('/(?<=[[:lower:]])(?=[[:upper:]])/', ' ', $namespace);
if ($group !== $currentGroup) {
$underline = str_repeat('-', \strlen($group));
$documentation .= "\n\n{$group}\n{$underline}\n";
$currentGroup = $group;
}
$path = './'.$this->locator->getFixerDocumentationFileRelativePath($fixer);
$attributes = [];
if ($fixer instanceof DeprecatedFixerInterface) {
$attributes[] = 'deprecated';
}
if ($fixer instanceof ExperimentalFixerInterface) {
$attributes[] = 'experimental';
}
if ($fixer->isRisky()) {
$attributes[] = 'risky';
}
$attributes = 0 === \count($attributes)
? ''
: ' *('.implode(', ', $attributes).')*';
$summary = str_replace('`', '``', $fixer->getDefinition()->getSummary());
$documentation .= <<<RST
- `{$fixer->getName()} <{$path}>`_{$attributes}
{$summary}
RST;
}
return "{$documentation}\n";
}
private function generateSampleDiff(FixerInterface $fixer, CodeSampleInterface $sample, int $sampleNumber, string $ruleName): string
{
if ($sample instanceof VersionSpecificCodeSampleInterface && !$sample->isSuitableFor(\PHP_VERSION_ID)) {
$existingFile = @file_get_contents($this->locator->getFixerDocumentationFilePath($fixer));
if (false !== $existingFile) {
Preg::match("/\\RExample #{$sampleNumber}\\R.+?(?<diff>\\R\\.\\. code-block:: diff\\R\\R.*?)\\R(?:\\R\\S|$)/s", $existingFile, $matches);
if (isset($matches['diff'])) {
return $matches['diff'];
}
}
$error = <<<RST
.. error::
Cannot generate diff for code sample #{$sampleNumber} of rule {$ruleName}:
the sample is not suitable for current version of PHP (%s).
RST;
return \sprintf($error, \PHP_VERSION);
}
$old = $sample->getCode();
$tokens = Tokens::fromCode($old);
$file = $sample instanceof FileSpecificCodeSampleInterface
? $sample->getSplFileInfo()
: new StdinFileInfo();
if ($fixer instanceof ConfigurableFixerInterface) {
$fixer->configure($sample->getConfiguration() ?? []);
}
$fixer->fix($file, $tokens);
$diff = $this->differ->diff($old, $tokens->generateCode());
$diff = Preg::replace('/@@[ \+\-\d,]+@@\n/', '', $diff);
$diff = Preg::replace('/\r/', '^M', $diff);
$diff = Preg::replace('/^ $/m', '', $diff);
$diff = Preg::replace('/\n$/', '', $diff);
$diff = RstUtils::indent($diff, 3);
return <<<RST
.. code-block:: diff
{$diff}
RST;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Documentation;
use PhpCsFixer\Preg;
/**
* @internal
*/
final class RstUtils
{
private function __construct()
{
// cannot create instance of util. class
}
public static function toRst(string $string, int $indent = 0): string
{
$string = wordwrap(self::ensureProperInlineCode($string), 80 - $indent);
return 0 === $indent ? $string : self::indent($string, $indent);
}
public static function ensureProperInlineCode(string $string): string
{
return Preg::replace('/(?<!`)(`[^`]+`)(?!`)/', '`$1`', $string);
}
public static function indent(string $string, int $indent): string
{
return Preg::replace('/(\n)(?!\n|$)/', '$1'.str_repeat(' ', $indent), $string);
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Documentation;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\RuleSet\DeprecatedRuleSetDescriptionInterface;
use PhpCsFixer\RuleSet\RuleSetDescriptionInterface;
use PhpCsFixer\Utils;
/**
* @readonly
*
* @internal
*/
final class RuleSetDocumentationGenerator
{
private DocumentationLocator $locator;
public function __construct(DocumentationLocator $locator)
{
$this->locator = $locator;
}
/**
* @param list<FixerInterface> $fixers
*/
public function generateRuleSetsDocumentation(RuleSetDescriptionInterface $definition, array $fixers): string
{
$fixerNames = [];
foreach ($fixers as $fixer) {
$fixerNames[$fixer->getName()] = $fixer;
}
$title = "Rule set ``{$definition->getName()}``";
$titleLine = str_repeat('=', \strlen($title));
$doc = "{$titleLine}\n{$title}\n{$titleLine}\n\n".$definition->getDescription();
$warnings = [];
if ($definition instanceof DeprecatedRuleSetDescriptionInterface) {
$deprecationDescription = <<<'RST'
This rule set is deprecated and will be removed in the next major version
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
RST;
$alternatives = $definition->getSuccessorsNames();
if (0 !== \count($alternatives)) {
$deprecationDescription .= RstUtils::toRst(
\sprintf(
"\n\nYou should use %s instead.",
Utils::naturalLanguageJoinWithBackticks($alternatives)
),
0
);
} else {
$deprecationDescription .= 'No replacement available.';
}
$warnings[] = $deprecationDescription;
}
if ($definition->isRisky()) {
$warnings[] = <<<'RST'
This set contains rules that are risky
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Using this rule set may lead to changes in your code's logic and behaviour. Use it with caution and review changes before incorporating them into your code base.
RST;
}
if ([] !== $warnings) {
$warningsHeader = 1 === \count($warnings) ? 'Warning' : 'Warnings';
$warningsHeaderLine = str_repeat('-', \strlen($warningsHeader));
$doc .= "\n\n".implode(
"\n",
[
$warningsHeader,
$warningsHeaderLine,
...$warnings,
]
);
}
$rules = $definition->getRules();
if ([] === $rules) {
$doc .= "\n\nThis is an empty set.";
} else {
$enabledRules = array_filter($rules, static fn ($config) => false !== $config);
$disabledRules = array_filter($rules, static fn ($config) => false === $config);
$listRules = function (array $rules) use (&$doc, $fixerNames): void {
foreach ($rules as $rule => $config) {
if (str_starts_with($rule, '@')) {
$ruleSetPath = $this->locator->getRuleSetsDocumentationFilePath($rule);
$ruleSetPath = substr($ruleSetPath, strrpos($ruleSetPath, '/'));
$doc .= "\n- `{$rule} <.{$ruleSetPath}>`_";
} else {
$path = Preg::replace(
'#^'.preg_quote($this->locator->getFixersDocumentationDirectoryPath(), '#').'/#',
'./../rules/',
$this->locator->getFixerDocumentationFilePath($fixerNames[$rule])
);
$doc .= "\n- `{$rule} <{$path}>`_";
}
if (!\is_bool($config)) {
$doc .= " with config:\n\n ``".Utils::toString($config)."``\n";
}
}
};
if ([] !== $enabledRules) {
$doc .= "\n\nRules\n-----\n";
$listRules($enabledRules);
}
if ([] !== $disabledRules) {
$doc .= "\n\nDisabled rules\n--------------\n";
$listRules($disabledRules);
}
}
return $doc."\n";
}
/**
* @param array<string, RuleSetDescriptionInterface> $setDefinitions
*/
public function generateRuleSetsDocumentationIndex(array $setDefinitions): string
{
$documentation = <<<'RST'
===========================
List of Available Rule sets
===========================
RST;
foreach ($setDefinitions as $path => $definition) {
$path = substr($path, strrpos($path, '/'));
$attributes = [];
if ($definition instanceof DeprecatedRuleSetDescriptionInterface) {
$attributes[] = 'deprecated';
}
$attributes = 0 === \count($attributes)
? ''
: ' *('.implode(', ', $attributes).')*';
$documentation .= "\n- `{$definition->getName()} <.{$path}>`_{$attributes}";
}
return $documentation."\n";
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Error;
/**
* An abstraction for errors that can occur before and during fixing.
*
* @author Andreas Möller <am@localheinz.com>
*
* @readonly
*
* @internal
*/
final class Error implements \JsonSerializable
{
/**
* Error which has occurred in linting phase, before applying any fixers.
*/
public const TYPE_INVALID = 1;
/**
* Error which has occurred during fixing phase.
*/
public const TYPE_EXCEPTION = 2;
/**
* Error which has occurred in linting phase, after applying any fixers.
*/
public const TYPE_LINT = 3;
/** @var self::TYPE_* */
private int $type;
private string $filePath;
private ?\Throwable $source;
/**
* @var list<string>
*/
private array $appliedFixers;
private ?string $diff;
/**
* @param self::TYPE_* $type
* @param list<string> $appliedFixers
*/
public function __construct(int $type, string $filePath, ?\Throwable $source = null, array $appliedFixers = [], ?string $diff = null)
{
$this->type = $type;
$this->filePath = $filePath;
$this->source = $source;
$this->appliedFixers = $appliedFixers;
$this->diff = $diff;
}
public function getFilePath(): string
{
return $this->filePath;
}
public function getSource(): ?\Throwable
{
return $this->source;
}
public function getType(): int
{
return $this->type;
}
/**
* @return list<string>
*/
public function getAppliedFixers(): array
{
return $this->appliedFixers;
}
public function getDiff(): ?string
{
return $this->diff;
}
/**
* @return array{
* type: self::TYPE_*,
* filePath: string,
* source: null|array{class: class-string, message: string, code: int, file: string, line: int},
* appliedFixers: list<string>,
* diff: null|string
* }
*/
public function jsonSerialize(): array
{
return [
'type' => $this->type,
'filePath' => $this->filePath,
'source' => null !== $this->source
? [
'class' => \get_class($this->source),
'message' => $this->source->getMessage(),
'code' => $this->source->getCode(),
'file' => $this->source->getFile(),
'line' => $this->source->getLine(),
]
: null,
'appliedFixers' => $this->appliedFixers,
'diff' => $this->diff,
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Error;
/**
* Manager of errors that occur during fixing.
*
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class ErrorsManager
{
/**
* @var list<Error>
*/
private array $errors = [];
/**
* Returns errors reported during linting before fixing.
*
* @return list<Error>
*/
public function getInvalidErrors(): array
{
return array_values(array_filter($this->errors, static fn (Error $error): bool => Error::TYPE_INVALID === $error->getType()));
}
/**
* Returns errors reported during fixing.
*
* @return list<Error>
*/
public function getExceptionErrors(): array
{
return array_values(array_filter($this->errors, static fn (Error $error): bool => Error::TYPE_EXCEPTION === $error->getType()));
}
/**
* Returns errors reported during linting after fixing.
*
* @return list<Error>
*/
public function getLintErrors(): array
{
return array_values(array_filter($this->errors, static fn (Error $error): bool => Error::TYPE_LINT === $error->getType()));
}
/**
* Returns errors reported for specified path.
*
* @return list<Error>
*/
public function forPath(string $path): array
{
return array_values(array_filter($this->errors, static fn (Error $error): bool => $path === $error->getFilePath()));
}
/**
* Returns true if no errors were reported.
*/
public function isEmpty(): bool
{
return [] === $this->errors;
}
public function report(Error $error): void
{
$this->errors[] = $error;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Error;
/**
* @readonly
*
* @internal
*/
final class SourceExceptionFactory
{
/**
* @param array{class: class-string<\Throwable>, message: string, code: int, file: string, line: int} $error
*/
public static function fromArray(array $error): \Throwable
{
$exceptionClass = $error['class'];
try {
$exception = new $exceptionClass($error['message'], $error['code']);
if (
$exception->getMessage() !== $error['message']
|| $exception->getCode() !== $error['code']
) {
throw new \RuntimeException('Failed to create exception from array. Message and code are not the same.');
}
} catch (\Throwable $e) {
$exception = new \RuntimeException(
\sprintf('[%s] %s', $exceptionClass, $error['message']),
$error['code']
);
}
try {
$exceptionReflection = new \ReflectionClass($exception);
foreach (['file', 'line'] as $property) {
$propertyReflection = $exceptionReflection->getProperty($property);
if (\PHP_VERSION_ID < 8_01_00) {
$propertyReflection->setAccessible(true);
}
$propertyReflection->setValue($exception, $error[$property]);
if (\PHP_VERSION_ID < 8_01_00) {
$propertyReflection->setAccessible(false);
}
}
} catch (\Throwable $reflectionException) {
// Ignore if we were not able to set file/line properties. In most cases it should be fine,
// we just need to make sure nothing is broken when we recreate errors from raw data passed from worker.
}
return $exception;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class ExecutorWithoutErrorHandler
{
private function __construct() {}
/**
* @template T
*
* @param callable(): T $callback
*
* @return T
*
* @throws ExecutorWithoutErrorHandlerException
*/
public static function execute(callable $callback)
{
/** @var ?string */
$error = null;
set_error_handler(static function (int $errorNumber, string $errorString, string $errorFile, int $errorLine) use (&$error): bool {
$error = $errorString;
return true;
});
try {
$result = $callback();
} finally {
restore_error_handler();
}
if (null !== $error) {
throw new ExecutorWithoutErrorHandlerException($error);
}
return $result;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
/**
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class ExecutorWithoutErrorHandlerException extends \RuntimeException {}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
/**
* File reader that unify access to regular file and stdin-alike file.
*
* Regular file could be read multiple times with `file_get_contents`, but file provided on stdin cannot.
* Consecutive try will provide empty content for stdin-alike file.
* This reader unifies access to them.
*
* @internal
*/
final class FileReader
{
private ?string $stdinContent = null;
public static function createSingleton(): self
{
static $instance = null;
if (!$instance) {
$instance = new self();
}
return $instance;
}
public function read(string $filePath): string
{
if ('php://stdin' === $filePath) {
if (null === $this->stdinContent) {
$this->stdinContent = $this->readRaw($filePath);
}
return $this->stdinContent;
}
return $this->readRaw($filePath);
}
private function readRaw(string $realPath): string
{
$content = @file_get_contents($realPath);
if (false === $content) {
$error = error_get_last();
throw new \RuntimeException(\sprintf(
'Failed to read content from "%s".%s',
$realPath,
null !== $error ? ' '.$error['message'] : ''
));
}
return $content;
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
/**
* Handles files removal with possibility to remove them on shutdown.
*
* @author Adam Klvač <adam@klva.cz>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* @internal
*/
final class FileRemoval
{
/**
* List of observed files to be removed.
*
* @var array<string, true>
*/
private array $files = [];
public function __construct()
{
register_shutdown_function([$this, 'clean']);
}
public function __destruct()
{
$this->clean();
}
/**
* This class is not intended to be serialized,
* and cannot be deserialized (see __wakeup method).
*/
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.self::class);
}
/**
* Disable the deserialization of the class to prevent attacker executing
* code by leveraging the __destruct method.
*
* @see https://owasp.org/www-community/vulnerabilities/PHP_Object_Injection
*/
public function __wakeup(): void
{
throw new \BadMethodCallException('Cannot unserialize '.self::class);
}
/**
* Adds a file to be removed.
*/
public function observe(string $path): void
{
$this->files[$path] = true;
}
/**
* Removes a file from shutdown removal.
*/
public function delete(string $path): void
{
if (isset($this->files[$path])) {
unset($this->files[$path]);
}
$this->unlink($path);
}
/**
* Removes attached files.
*/
public function clean(): void
{
foreach ($this->files as $file => $value) {
$this->unlink($file);
}
$this->files = [];
}
private function unlink(string $path): void
{
@unlink($path);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer;
use Symfony\Component\Finder\Finder as BaseFinder;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
class Finder extends BaseFinder
{
public function __construct()
{
parent::__construct();
$this
->files()
->name('/\.php$/')
->exclude('vendor')
;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\Tokenizer\Tokens;
abstract class AbstractIncrementOperatorFixer extends AbstractFixer
{
final protected function findStart(Tokens $tokens, int $index): int
{
do {
$index = $tokens->getPrevMeaningfulToken($index);
$token = $tokens[$index];
$blockType = Tokens::detectBlockType($token);
if (null !== $blockType && !$blockType['isStart']) {
$index = $tokens->findBlockStart($blockType['type'], $index);
$token = $tokens[$index];
}
} while (!$token->equalsAny(['$', [\T_VARIABLE]]));
$prevIndex = $tokens->getPrevMeaningfulToken($index);
$prevToken = $tokens[$prevIndex];
if ($prevToken->equals('$')) {
return $this->findStart($tokens, $index);
}
if ($prevToken->isObjectOperator()) {
return $this->findStart($tokens, $prevIndex);
}
if ($prevToken->isGivenKind(\T_PAAMAYIM_NEKUDOTAYIM)) {
$prevPrevIndex = $tokens->getPrevMeaningfulToken($prevIndex);
if (!$tokens[$prevPrevIndex]->isGivenKind([\T_STATIC, \T_STRING])) {
return $this->findStart($tokens, $prevIndex);
}
$index = $tokens->getTokenNotOfKindsSibling($prevIndex, -1, [\T_NS_SEPARATOR, \T_STATIC, \T_STRING]);
$index = $tokens->getNextMeaningfulToken($index);
}
return $index;
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\DocBlock\DocBlock;
use PhpCsFixer\DocBlock\Line;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\AttributeAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\FullyQualifiedNameAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\PhpUnitTestCaseAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\WhitespacesAnalyzer;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\FCT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @internal
*/
abstract class AbstractPhpUnitFixer extends AbstractFixer
{
private const DOC_BLOCK_MODIFIERS = [\T_PUBLIC, \T_PROTECTED, \T_PRIVATE, \T_FINAL, \T_ABSTRACT, \T_COMMENT, FCT::T_ATTRIBUTE, FCT::T_READONLY];
private const ATTRIBUTE_MODIFIERS = [\T_FINAL, FCT::T_READONLY];
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isAllTokenKindsFound([\T_CLASS, \T_STRING]);
}
final protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
foreach ((new PhpUnitTestCaseAnalyzer())->findPhpUnitClasses($tokens) as $indices) {
$this->applyPhpUnitClassFix($tokens, $indices[0], $indices[1]);
}
}
abstract protected function applyPhpUnitClassFix(Tokens $tokens, int $startIndex, int $endIndex): void;
final protected function getDocBlockIndex(Tokens $tokens, int $index): int
{
do {
$index = $tokens->getPrevNonWhitespace($index);
if ($tokens[$index]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
$index = $tokens->getPrevTokenOfKind($index, [[\T_ATTRIBUTE]]);
}
} while ($tokens[$index]->isGivenKind(self::DOC_BLOCK_MODIFIERS));
return $index;
}
/**
* @param list<string> $preventingAnnotations
* @param list<class-string> $preventingAttributes
*/
final protected function ensureIsDocBlockWithAnnotation(
Tokens $tokens,
int $index,
string $annotation,
array $preventingAnnotations,
array $preventingAttributes
): void {
$docBlockIndex = $this->getDocBlockIndex($tokens, $index);
if (self::isPreventedByAttribute($tokens, $index, $preventingAttributes)) {
return;
}
if ($this->isPHPDoc($tokens, $docBlockIndex)) {
$this->updateDocBlockIfNeeded($tokens, $docBlockIndex, $annotation, $preventingAnnotations);
} else {
$this->createDocBlock($tokens, $docBlockIndex, $annotation);
}
}
final protected function isPHPDoc(Tokens $tokens, int $index): bool
{
return $tokens[$index]->isGivenKind(\T_DOC_COMMENT);
}
/**
* @return iterable<array{
* index: int,
* loweredName: string,
* openBraceIndex: int,
* closeBraceIndex: int,
* }>
*/
protected function getPreviousAssertCall(Tokens $tokens, int $startIndex, int $endIndex): iterable
{
$functionsAnalyzer = new FunctionsAnalyzer();
for ($index = $endIndex; $index > $startIndex; --$index) {
$index = $tokens->getPrevTokenOfKind($index, [[\T_STRING]]);
if (null === $index) {
return;
}
// test if "assert" something call
$loweredContent = strtolower($tokens[$index]->getContent());
if (!str_starts_with($loweredContent, 'assert')) {
continue;
}
// test candidate for simple calls like: ([\]+'some fixable call'(...))
$openBraceIndex = $tokens->getNextMeaningfulToken($index);
if (!$tokens[$openBraceIndex]->equals('(')) {
continue;
}
if (!$functionsAnalyzer->isTheSameClassCall($tokens, $index)) {
continue;
}
yield [
'index' => $index,
'loweredName' => $loweredContent,
'openBraceIndex' => $openBraceIndex,
'closeBraceIndex' => $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openBraceIndex),
];
}
}
final protected function isTestAttributePresent(Tokens $tokens, int $index): bool
{
$attributeIndex = $tokens->getPrevTokenOfKind($index, ['{', [FCT::T_ATTRIBUTE]]);
if (!$tokens[$attributeIndex]->isGivenKind(FCT::T_ATTRIBUTE)) {
return false;
}
$fullyQualifiedNameAnalyzer = new FullyQualifiedNameAnalyzer($tokens);
foreach (AttributeAnalyzer::collect($tokens, $attributeIndex) as $attributeAnalysis) {
foreach ($attributeAnalysis->getAttributes() as $attribute) {
$attributeName = strtolower($fullyQualifiedNameAnalyzer->getFullyQualifiedName($attribute['name'], $attribute['start'], NamespaceUseAnalysis::TYPE_CLASS));
if ('phpunit\framework\attributes\test' === $attributeName) {
return true;
}
}
}
return false;
}
private function createDocBlock(Tokens $tokens, int $docBlockIndex, string $annotation): void
{
$lineEnd = $this->whitespacesConfig->getLineEnding();
$originalIndent = WhitespacesAnalyzer::detectIndent($tokens, $tokens->getNextNonWhitespace($docBlockIndex));
$toInsert = [
new Token([\T_DOC_COMMENT, "/**{$lineEnd}{$originalIndent} * @{$annotation}{$lineEnd}{$originalIndent} */"]),
new Token([\T_WHITESPACE, $lineEnd.$originalIndent]),
];
$index = $tokens->getNextMeaningfulToken($docBlockIndex);
$tokens->insertAt($index, $toInsert);
if (!$tokens[$index - 1]->isGivenKind(\T_WHITESPACE)) {
$extraNewLines = $this->whitespacesConfig->getLineEnding();
if (!$tokens[$index - 1]->isGivenKind(\T_OPEN_TAG)) {
$extraNewLines .= $this->whitespacesConfig->getLineEnding();
}
$tokens->insertAt($index, [
new Token([\T_WHITESPACE, $extraNewLines.WhitespacesAnalyzer::detectIndent($tokens, $index)]),
]);
}
}
/**
* @param list<string> $preventingAnnotations
*/
private function updateDocBlockIfNeeded(
Tokens $tokens,
int $docBlockIndex,
string $annotation,
array $preventingAnnotations
): void {
$doc = new DocBlock($tokens[$docBlockIndex]->getContent());
foreach ($preventingAnnotations as $preventingAnnotation) {
if ([] !== $doc->getAnnotationsOfType($preventingAnnotation)) {
return;
}
}
$doc = $this->makeDocBlockMultiLineIfNeeded($doc, $tokens, $docBlockIndex, $annotation);
$lines = $this->addInternalAnnotation($doc, $tokens, $docBlockIndex, $annotation);
$lines = implode('', $lines);
$tokens->getNamespaceDeclarations();
$tokens[$docBlockIndex] = new Token([\T_DOC_COMMENT, $lines]);
}
/**
* @param list<class-string> $preventingAttributes
*/
private static function isPreventedByAttribute(Tokens $tokens, int $index, array $preventingAttributes): bool
{
if ([] === $preventingAttributes) {
return false;
}
do {
$index = $tokens->getPrevMeaningfulToken($index);
} while ($tokens[$index]->isGivenKind(self::ATTRIBUTE_MODIFIERS));
if (!$tokens[$index]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
return false;
}
$index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ATTRIBUTE, $index);
$fullyQualifiedNameAnalyzer = new FullyQualifiedNameAnalyzer($tokens);
foreach (AttributeAnalyzer::collect($tokens, $index) as $attributeAnalysis) {
foreach ($attributeAnalysis->getAttributes() as $attribute) {
if (\in_array(strtolower($fullyQualifiedNameAnalyzer->getFullyQualifiedName($attribute['name'], $attribute['start'], NamespaceUseAnalysis::TYPE_CLASS)), $preventingAttributes, true)) {
return true;
}
}
}
return false;
}
/**
* @return list<Line>
*/
private function addInternalAnnotation(DocBlock $docBlock, Tokens $tokens, int $docBlockIndex, string $annotation): array
{
$lines = $docBlock->getLines();
$originalIndent = WhitespacesAnalyzer::detectIndent($tokens, $docBlockIndex);
$lineEnd = $this->whitespacesConfig->getLineEnding();
array_splice($lines, -1, 0, [new Line($originalIndent.' * @'.$annotation.$lineEnd)]);
return $lines;
}
private function makeDocBlockMultiLineIfNeeded(DocBlock $doc, Tokens $tokens, int $docBlockIndex, string $annotation): DocBlock
{
$lines = $doc->getLines();
if (1 === \count($lines) && [] === $doc->getAnnotationsOfType($annotation)) {
$indent = WhitespacesAnalyzer::detectIndent($tokens, $tokens->getNextNonWhitespace($docBlockIndex));
$doc->makeMultiLine($indent, $this->whitespacesConfig->getLineEnding());
return $doc;
}
return $doc;
}
}

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\Tokenizer\Analyzer\AlternativeSyntaxAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\RangeAnalyzer;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @internal
*/
abstract class AbstractShortOperatorFixer extends AbstractFixer
{
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$alternativeSyntaxAnalyzer = new AlternativeSyntaxAnalyzer();
for ($index = \count($tokens) - 1; $index > 3; --$index) {
if (!$this->isOperatorTokenCandidate($tokens, $index)) {
continue;
}
// get what is before the operator
$beforeRange = $this->getBeforeOperatorRange($tokens, $index);
$equalsIndex = $tokens->getPrevMeaningfulToken($beforeRange['start']);
// make sure that before that is '='
if (!$tokens[$equalsIndex]->equals('=')) {
continue;
}
// get and check what is before '='
$assignRange = $this->getBeforeOperatorRange($tokens, $equalsIndex);
$beforeAssignmentIndex = $tokens->getPrevMeaningfulToken($assignRange['start']);
if ($tokens[$beforeAssignmentIndex]->equals(':')) {
if (!$this->belongsToSwitchOrAlternativeSyntax($alternativeSyntaxAnalyzer, $tokens, $beforeAssignmentIndex)) {
continue;
}
} elseif (!$tokens[$beforeAssignmentIndex]->equalsAny([';', '{', '}', '(', ')', ',', [\T_OPEN_TAG], [\T_RETURN]])) {
continue;
}
// check if "assign" and "before" the operator are (functionally) the same
if (RangeAnalyzer::rangeEqualsRange($tokens, $assignRange, $beforeRange)) {
$this->shortenOperation($tokens, $equalsIndex, $index, $assignRange, $beforeRange);
continue;
}
if (!$this->isOperatorCommutative($tokens[$index])) {
continue;
}
$afterRange = $this->getAfterOperatorRange($tokens, $index);
// check if "assign" and "after" the operator are (functionally) the same
if (!RangeAnalyzer::rangeEqualsRange($tokens, $assignRange, $afterRange)) {
continue;
}
$this->shortenOperation($tokens, $equalsIndex, $index, $assignRange, $afterRange);
}
}
abstract protected function getReplacementToken(Token $token): Token;
abstract protected function isOperatorTokenCandidate(Tokens $tokens, int $index): bool;
/**
* @param array{start: int, end: int} $assignRange
* @param array{start: int, end: int} $operatorRange
*/
private function shortenOperation(
Tokens $tokens,
int $equalsIndex,
int $operatorIndex,
array $assignRange,
array $operatorRange
): void {
$tokens[$equalsIndex] = $this->getReplacementToken($tokens[$operatorIndex]);
$tokens->clearTokenAndMergeSurroundingWhitespace($operatorIndex);
$this->clearMeaningfulFromRange($tokens, $operatorRange);
foreach ([$equalsIndex, $assignRange['end']] as $i) {
$i = $tokens->getNonEmptySibling($i, 1);
if ($tokens[$i]->isWhitespace(" \t")) {
$tokens[$i] = new Token([\T_WHITESPACE, ' ']);
} elseif (!$tokens[$i]->isWhitespace()) {
$tokens->insertAt($i, new Token([\T_WHITESPACE, ' ']));
}
}
}
/**
* @return array{start: int, end: int}
*/
private function getAfterOperatorRange(Tokens $tokens, int $index): array
{
$index = $tokens->getNextMeaningfulToken($index);
$range = ['start' => $index];
while (true) {
$nextIndex = $tokens->getNextMeaningfulToken($index);
if (null === $nextIndex || $tokens[$nextIndex]->equalsAny([';', ',', [\T_CLOSE_TAG]])) {
break;
}
$blockType = Tokens::detectBlockType($tokens[$nextIndex]);
if (null === $blockType) {
$index = $nextIndex;
continue;
}
if (false === $blockType['isStart']) {
break;
}
$index = $tokens->findBlockEnd($blockType['type'], $nextIndex);
}
$range['end'] = $index;
return $range;
}
/**
* @return array{start: int, end: int}
*/
private function getBeforeOperatorRange(Tokens $tokens, int $index): array
{
static $blockOpenTypes;
if (null === $blockOpenTypes) {
$blockOpenTypes = [',']; // not a true "block type", but speeds up things
foreach (Tokens::getBlockEdgeDefinitions() as $definition) {
$blockOpenTypes[] = $definition['start'];
}
}
$controlStructureWithoutBracesTypes = [\T_IF, \T_ELSE, \T_ELSEIF, \T_FOR, \T_FOREACH, \T_WHILE];
$previousIndex = $tokens->getPrevMeaningfulToken($index);
$previousToken = $tokens[$previousIndex];
if ($tokens[$previousIndex]->equalsAny($blockOpenTypes)) {
return ['start' => $index, 'end' => $index];
}
$range = ['end' => $previousIndex];
$index = $previousIndex;
while ($previousToken->equalsAny([
'$',
']',
')',
[CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE],
[CT::T_DYNAMIC_PROP_BRACE_CLOSE],
[CT::T_DYNAMIC_VAR_BRACE_CLOSE],
[\T_NS_SEPARATOR],
[\T_STRING],
[\T_VARIABLE],
])) {
$blockType = Tokens::detectBlockType($previousToken);
if (null !== $blockType) {
$blockStart = $tokens->findBlockStart($blockType['type'], $previousIndex);
if ($tokens[$previousIndex]->equals(')') && $tokens[$tokens->getPrevMeaningfulToken($blockStart)]->isGivenKind($controlStructureWithoutBracesTypes)) {
break; // we went too far back
}
$previousIndex = $blockStart;
}
$index = $previousIndex;
$previousIndex = $tokens->getPrevMeaningfulToken($previousIndex);
$previousToken = $tokens[$previousIndex];
}
if ($previousToken->isGivenKind(\T_OBJECT_OPERATOR)) {
$index = $this->getBeforeOperatorRange($tokens, $previousIndex)['start'];
} elseif ($previousToken->isGivenKind(\T_PAAMAYIM_NEKUDOTAYIM)) {
$index = $this->getBeforeOperatorRange($tokens, $tokens->getPrevMeaningfulToken($previousIndex))['start'];
}
$range['start'] = $index;
return $range;
}
/**
* @param array{start: int, end: int} $range
*/
private function clearMeaningfulFromRange(Tokens $tokens, array $range): void
{
// $range['end'] must be meaningful!
for ($i = $range['end']; $i >= $range['start']; $i = $tokens->getPrevMeaningfulToken($i)) {
$tokens->clearTokenAndMergeSurroundingWhitespace($i);
}
}
private function isOperatorCommutative(Token $operatorToken): bool
{
if ($operatorToken->isGivenKind(\T_COALESCE)) {
return false;
}
// check for commutative kinds
if ($operatorToken->equalsAny(['*', '|', '&', '^'])) { // note that for arrays in PHP `+` is not commutative
return true;
}
// check for non-commutative kinds
if ($operatorToken->equalsAny(['-', '/', '.', '%', '+'])) {
return false;
}
throw new \InvalidArgumentException(\sprintf('Not supported operator "%s".', $operatorToken->toJson()));
}
private function belongsToSwitchOrAlternativeSyntax(AlternativeSyntaxAnalyzer $alternativeSyntaxAnalyzer, Tokens $tokens, int $index): bool
{
$candidate = $index;
$index = $tokens->getPrevMeaningfulToken($candidate);
if ($tokens[$index]->isGivenKind(\T_DEFAULT)) {
return true;
}
$index = $tokens->getPrevMeaningfulToken($index);
if ($tokens[$index]->isGivenKind(\T_CASE)) {
return true;
}
return $alternativeSyntaxAnalyzer->belongsToAlternativeSyntax($tokens, $candidate);
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer\Alias;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
final class ArrayPushFixer extends AbstractFixer
{
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Converts simple usages of `array_push($x, $y);` to `$x[] = $y;`.',
[new CodeSample("<?php\narray_push(\$x, \$y);\n")],
null,
'Risky when the function `array_push` is overridden.'
);
}
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(\T_STRING) && $tokens->count() > 7;
}
public function isRisky(): bool
{
return true;
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$functionsAnalyzer = new FunctionsAnalyzer();
for ($index = $tokens->count() - 7; $index > 0; --$index) {
if (!$tokens[$index]->equals([\T_STRING, 'array_push'], false)) {
continue;
}
if (!$functionsAnalyzer->isGlobalFunctionCall($tokens, $index)) {
continue; // redeclare/override
}
// meaningful before must be `<?php`, `{`, `}` or `;`
$callIndex = $index;
$index = $tokens->getPrevMeaningfulToken($index);
$namespaceSeparatorIndex = null;
if ($tokens[$index]->isGivenKind(\T_NS_SEPARATOR)) {
$namespaceSeparatorIndex = $index;
$index = $tokens->getPrevMeaningfulToken($index);
}
if (!$tokens[$index]->equalsAny([';', '{', '}', ')', [\T_OPEN_TAG]])) {
continue;
}
// figure out where the arguments list opens
$openBraceIndex = $tokens->getNextMeaningfulToken($callIndex);
$blockType = Tokens::detectBlockType($tokens[$openBraceIndex]);
if (null === $blockType || Tokens::BLOCK_TYPE_PARENTHESIS_BRACE !== $blockType['type']) {
continue;
}
// figure out where the arguments list closes
$closeBraceIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openBraceIndex);
// meaningful after `)` must be `;`, `? >` or nothing
$afterCloseBraceIndex = $tokens->getNextMeaningfulToken($closeBraceIndex);
if (null !== $afterCloseBraceIndex && !$tokens[$afterCloseBraceIndex]->equalsAny([';', [\T_CLOSE_TAG]])) {
continue;
}
// must have 2 arguments
// first argument must be a variable (with possibly array indexing etc.),
// after that nothing meaningful should be there till the next `,` or `)`
// if `)` than we cannot fix it (it is a single argument call)
$firstArgumentStop = $this->getFirstArgumentEnd($tokens, $openBraceIndex);
$firstArgumentStop = $tokens->getNextMeaningfulToken($firstArgumentStop);
if (!$tokens[$firstArgumentStop]->equals(',')) {
return;
}
// second argument can be about anything but ellipsis, we must make sure there is not
// a third argument (or more) passed to `array_push`
$secondArgumentStart = $tokens->getNextMeaningfulToken($firstArgumentStop);
$secondArgumentStop = $this->getSecondArgumentEnd($tokens, $secondArgumentStart, $closeBraceIndex);
if (null === $secondArgumentStop) {
continue;
}
// candidate is valid, replace tokens
$tokens->clearTokenAndMergeSurroundingWhitespace($closeBraceIndex);
$tokens->clearTokenAndMergeSurroundingWhitespace($firstArgumentStop);
$tokens->insertAt(
$firstArgumentStop,
[
new Token('['),
new Token(']'),
new Token([\T_WHITESPACE, ' ']),
new Token('='),
]
);
$tokens->clearTokenAndMergeSurroundingWhitespace($openBraceIndex);
$tokens->clearTokenAndMergeSurroundingWhitespace($callIndex);
if (null !== $namespaceSeparatorIndex) {
$tokens->clearTokenAndMergeSurroundingWhitespace($namespaceSeparatorIndex);
}
}
}
private function getFirstArgumentEnd(Tokens $tokens, int $index): int
{
$nextIndex = $tokens->getNextMeaningfulToken($index);
$nextToken = $tokens[$nextIndex];
while ($nextToken->equalsAny([
'$',
'[',
'(',
[CT::T_ARRAY_INDEX_CURLY_BRACE_OPEN],
[CT::T_DYNAMIC_PROP_BRACE_OPEN],
[CT::T_DYNAMIC_VAR_BRACE_OPEN],
[CT::T_NAMESPACE_OPERATOR],
[\T_NS_SEPARATOR],
[\T_STATIC],
[\T_STRING],
[\T_VARIABLE],
])) {
$blockType = Tokens::detectBlockType($nextToken);
if (null !== $blockType) {
$nextIndex = $tokens->findBlockEnd($blockType['type'], $nextIndex);
}
$index = $nextIndex;
$nextIndex = $tokens->getNextMeaningfulToken($nextIndex);
$nextToken = $tokens[$nextIndex];
}
if ($nextToken->isGivenKind(\T_OBJECT_OPERATOR)) {
return $this->getFirstArgumentEnd($tokens, $nextIndex);
}
if ($nextToken->isGivenKind(\T_PAAMAYIM_NEKUDOTAYIM)) {
return $this->getFirstArgumentEnd($tokens, $tokens->getNextMeaningfulToken($nextIndex));
}
return $index;
}
/**
* @param int $endIndex boundary, i.e. tokens index of `)`
*/
private function getSecondArgumentEnd(Tokens $tokens, int $index, int $endIndex): ?int
{
if ($tokens[$index]->isGivenKind(\T_ELLIPSIS)) {
return null;
}
for (; $index <= $endIndex; ++$index) {
$blockType = Tokens::detectBlockType($tokens[$index]);
while (null !== $blockType && $blockType['isStart']) {
$index = $tokens->findBlockEnd($blockType['type'], $index);
$index = $tokens->getNextMeaningfulToken($index);
$blockType = Tokens::detectBlockType($tokens[$index]);
}
if ($tokens[$index]->equals(',') || $tokens[$index]->isGivenKind([\T_YIELD, \T_YIELD_FROM, \T_LOGICAL_AND, \T_LOGICAL_OR, \T_LOGICAL_XOR])) {
return null;
}
}
return $endIndex;
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer\Alias;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @author Filippo Tessarotto <zoeslam@gmail.com>
*/
final class BacktickToShellExecFixer extends AbstractFixer
{
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound('`');
}
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Converts backtick operators to `shell_exec` calls.',
[
new CodeSample(
<<<'EOT'
<?php
$plain = `ls -lah`;
$withVar = `ls -lah $var1 ${var2} {$var3} {$var4[0]} {$var5->call()}`;
EOT
),
],
'Conversion is done only when it is non risky, so when special chars like single-quotes, double-quotes and backticks are not used inside the command.'
);
}
/**
* {@inheritdoc}
*
* Must run before ExplicitStringVariableFixer, NativeFunctionInvocationFixer, SingleQuoteFixer.
*/
public function getPriority(): int
{
return 17;
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$backtickStarted = false;
$backtickTokens = [];
for ($index = $tokens->count() - 1; $index > 0; --$index) {
$token = $tokens[$index];
if (!$token->equals('`')) {
if ($backtickStarted) {
$backtickTokens[$index] = $token;
}
continue;
}
$backtickTokens[$index] = $token;
if ($backtickStarted) {
$this->fixBackticks($tokens, $backtickTokens);
$backtickTokens = [];
}
$backtickStarted = !$backtickStarted;
}
}
/**
* Override backtick code with corresponding double-quoted string.
*
* @param array<int, Token> $backtickTokens
*/
private function fixBackticks(Tokens $tokens, array $backtickTokens): void
{
// Track indices for final override
ksort($backtickTokens);
$openingBacktickIndex = array_key_first($backtickTokens);
$closingBacktickIndex = array_key_last($backtickTokens);
// Strip enclosing backticks
array_shift($backtickTokens);
array_pop($backtickTokens);
// Double-quoted strings are parsed differently if they contain
// variables or not, so we need to build the new token array accordingly
$count = \count($backtickTokens);
$newTokens = [
new Token([\T_STRING, 'shell_exec']),
new Token('('),
];
if (1 !== $count) {
$newTokens[] = new Token('"');
}
foreach ($backtickTokens as $token) {
if (!$token->isGivenKind(\T_ENCAPSED_AND_WHITESPACE)) {
$newTokens[] = $token;
continue;
}
$content = $token->getContent();
// Escaping special chars depends on the context: too tricky
if (Preg::match('/[`"\']/u', $content)) {
return;
}
$kind = \T_ENCAPSED_AND_WHITESPACE;
if (1 === $count) {
$content = '"'.$content.'"';
$kind = \T_CONSTANT_ENCAPSED_STRING;
}
$newTokens[] = new Token([$kind, $content]);
}
if (1 !== $count) {
$newTokens[] = new Token('"');
}
$newTokens[] = new Token(')');
$tokens->overrideRange($openingBacktickIndex, $closingBacktickIndex, $newTokens);
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer\Alias;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\PregException;
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @author Matteo Beccati <matteo@beccati.com>
*/
final class EregToPregFixer extends AbstractFixer
{
/**
* @var list<array<int, string>> the list of the ext/ereg function names, their preg equivalent and the preg modifier(s), if any
* all condensed in an array of arrays
*/
private const FUNCTIONS = [
['ereg', 'preg_match', ''],
['eregi', 'preg_match', 'i'],
['ereg_replace', 'preg_replace', ''],
['eregi_replace', 'preg_replace', 'i'],
['split', 'preg_split', ''],
['spliti', 'preg_split', 'i'],
];
/**
* @var list<string> the list of preg delimiters, in order of preference
*/
private static array $delimiters = ['/', '#', '!'];
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Replace deprecated `ereg` regular expression functions with `preg`.',
[new CodeSample("<?php \$x = ereg('[A-Z]');\n")],
null,
'Risky if the `ereg` function is overridden.'
);
}
/**
* {@inheritdoc}
*
* Must run after NoUselessConcatOperatorFixer.
*/
public function getPriority(): int
{
return 0;
}
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(\T_STRING);
}
public function isRisky(): bool
{
return true;
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$end = $tokens->count() - 1;
$functionsAnalyzer = new FunctionsAnalyzer();
foreach (self::FUNCTIONS as $map) {
// the sequence is the function name, followed by "(" and a quoted string
$seq = [[\T_STRING, $map[0]], '(', [\T_CONSTANT_ENCAPSED_STRING]];
$currIndex = 0;
while (true) {
$match = $tokens->findSequence($seq, $currIndex, $end, false);
// did we find a match?
if (null === $match) {
break;
}
// findSequence also returns the tokens, but we're only interested in the indices, i.e.:
// 0 => function name,
// 1 => parenthesis "("
// 2 => quoted string passed as 1st parameter
$match = array_keys($match);
// advance tokenizer cursor
$currIndex = $match[2];
if (!$functionsAnalyzer->isGlobalFunctionCall($tokens, $match[0])) {
continue;
}
// ensure the first parameter is just a string (e.g. has nothing appended)
$next = $tokens->getNextMeaningfulToken($match[2]);
if (null === $next || !$tokens[$next]->equalsAny([',', ')'])) {
continue;
}
// convert to PCRE
$regexTokenContent = $tokens[$match[2]]->getContent();
if ('b' === $regexTokenContent[0] || 'B' === $regexTokenContent[0]) {
$quote = $regexTokenContent[1];
$prefix = $regexTokenContent[0];
$string = substr($regexTokenContent, 2, -1);
} else {
$quote = $regexTokenContent[0];
$prefix = '';
$string = substr($regexTokenContent, 1, -1);
}
$delim = $this->getBestDelimiter($string);
$preg = $delim.addcslashes($string, $delim).$delim.'D'.$map[2];
// check if the preg is valid
if (!$this->checkPreg($preg)) {
continue;
}
// modify function and argument
$tokens[$match[0]] = new Token([\T_STRING, $map[1]]);
$tokens[$match[2]] = new Token([\T_CONSTANT_ENCAPSED_STRING, $prefix.$quote.$preg.$quote]);
}
}
}
/**
* Check the validity of a PCRE.
*
* @param string $pattern the regular expression
*/
private function checkPreg(string $pattern): bool
{
try {
Preg::match($pattern, '');
return true;
} catch (PregException $e) {
return false;
}
}
/**
* Get the delimiter that would require the least escaping in a regular expression.
*
* @param string $pattern the regular expression
*
* @return string the preg delimiter
*/
private function getBestDelimiter(string $pattern): string
{
// try to find something that's not used
$delimiters = [];
foreach (self::$delimiters as $k => $d) {
if (!str_contains($pattern, $d)) {
return $d;
}
$delimiters[$d] = [substr_count($pattern, $d), $k];
}
// return the least used delimiter, using the position in the list as a tiebreaker
uasort($delimiters, static function (array $a, array $b): int {
if ($a[0] === $b[0]) {
return $a[1] <=> $b[1];
}
return $a[0] <=> $b[0];
});
return array_key_first($delimiters);
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer\Alias;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Tokenizer\Analyzer\ArgumentsAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @author Filippo Tessarotto <zoeslam@gmail.com>
*/
final class MbStrFunctionsFixer extends AbstractFixer
{
/**
* list of the string-related function names and their mb_ equivalent.
*
* @var array<
* string,
* array{
* alternativeName: string,
* argumentCount: list<int>,
* },
* >
*/
private static array $functionsMap = [
'str_split' => ['alternativeName' => 'mb_str_split', 'argumentCount' => [1, 2, 3]],
'stripos' => ['alternativeName' => 'mb_stripos', 'argumentCount' => [2, 3]],
'stristr' => ['alternativeName' => 'mb_stristr', 'argumentCount' => [2, 3]],
'strlen' => ['alternativeName' => 'mb_strlen', 'argumentCount' => [1]],
'strpos' => ['alternativeName' => 'mb_strpos', 'argumentCount' => [2, 3]],
'strrchr' => ['alternativeName' => 'mb_strrchr', 'argumentCount' => [2]],
'strripos' => ['alternativeName' => 'mb_strripos', 'argumentCount' => [2, 3]],
'strrpos' => ['alternativeName' => 'mb_strrpos', 'argumentCount' => [2, 3]],
'strstr' => ['alternativeName' => 'mb_strstr', 'argumentCount' => [2, 3]],
'strtolower' => ['alternativeName' => 'mb_strtolower', 'argumentCount' => [1]],
'strtoupper' => ['alternativeName' => 'mb_strtoupper', 'argumentCount' => [1]],
'substr' => ['alternativeName' => 'mb_substr', 'argumentCount' => [2, 3]],
'substr_count' => ['alternativeName' => 'mb_substr_count', 'argumentCount' => [2, 3, 4]],
];
/**
* @var array<
* string,
* array{
* alternativeName: string,
* argumentCount: list<int>,
* },
* >
*/
private array $functions;
public function __construct()
{
parent::__construct();
if (\PHP_VERSION_ID >= 8_03_00) {
self::$functionsMap['str_pad'] = ['alternativeName' => 'mb_str_pad', 'argumentCount' => [1, 2, 3, 4]];
}
if (\PHP_VERSION_ID >= 8_04_00) {
self::$functionsMap['trim'] = ['alternativeName' => 'mb_trim', 'argumentCount' => [1, 2]];
self::$functionsMap['ltrim'] = ['alternativeName' => 'mb_ltrim', 'argumentCount' => [1, 2]];
self::$functionsMap['rtrim'] = ['alternativeName' => 'mb_rtrim', 'argumentCount' => [1, 2]];
}
$this->functions = array_filter(
self::$functionsMap,
static fn (array $mapping): bool => (new \ReflectionFunction($mapping['alternativeName']))->isInternal()
);
}
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(\T_STRING);
}
public function isRisky(): bool
{
return true;
}
/**
* {@inheritdoc}
*
* Must run before NativeFunctionInvocationFixer.
*/
public function getPriority(): int
{
return 2;
}
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Replace non multibyte-safe functions with corresponding mb function.',
[
new CodeSample(
'<?php
$a = strlen($a);
$a = strpos($a, $b);
$a = strrpos($a, $b);
$a = substr($a, $b);
$a = strtolower($a);
$a = strtoupper($a);
$a = stripos($a, $b);
$a = strripos($a, $b);
$a = strstr($a, $b);
$a = stristr($a, $b);
$a = strrchr($a, $b);
$a = substr_count($a, $b);
'
),
],
null,
'Risky when any of the functions are overridden, or when relying on the string byte size rather than its length in characters.'
);
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$argumentsAnalyzer = new ArgumentsAnalyzer();
$functionsAnalyzer = new FunctionsAnalyzer();
for ($index = $tokens->count() - 1; $index > 0; --$index) {
if (!$tokens[$index]->isGivenKind(\T_STRING)) {
continue;
}
$lowercasedContent = strtolower($tokens[$index]->getContent());
if (!isset($this->functions[$lowercasedContent])) {
continue;
}
// is it a global function call?
if ($functionsAnalyzer->isGlobalFunctionCall($tokens, $index)) {
$openParenthesis = $tokens->getNextMeaningfulToken($index);
$closeParenthesis = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openParenthesis);
$numberOfArguments = $argumentsAnalyzer->countArguments($tokens, $openParenthesis, $closeParenthesis);
if (!\in_array($numberOfArguments, $this->functions[$lowercasedContent]['argumentCount'], true)) {
continue;
}
$tokens[$index] = new Token([\T_STRING, $this->functions[$lowercasedContent]['alternativeName']]);
continue;
}
// is it a global function import?
$functionIndex = $tokens->getPrevMeaningfulToken($index);
if ($tokens[$functionIndex]->isGivenKind(\T_NS_SEPARATOR)) {
$functionIndex = $tokens->getPrevMeaningfulToken($functionIndex);
}
if (!$tokens[$functionIndex]->isGivenKind(CT::T_FUNCTION_IMPORT)) {
continue;
}
$useIndex = $tokens->getPrevMeaningfulToken($functionIndex);
if (!$tokens[$useIndex]->isGivenKind(\T_USE)) {
continue;
}
$tokens[$index] = new Token([\T_STRING, $this->functions[$lowercasedContent]['alternativeName']]);
}
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\Fixer\Alias;
use PhpCsFixer\AbstractFixer;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
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\Tokenizer\Analyzer\ArgumentsAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
/**
* @phpstan-type _AutogeneratedInputConfiguration array{
* modernize_stripos?: bool,
* }
* @phpstan-type _AutogeneratedComputedConfiguration array{
* modernize_stripos: bool,
* }
*
* @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
*
* @author Alexander M. Turek <me@derrabus.de>
*/
final class ModernizeStrposFixer extends AbstractFixer implements ConfigurableFixerInterface
{
/** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
use ConfigurableFixerTrait;
private const REPLACEMENTS = [
[
'operator' => [\T_IS_IDENTICAL, '==='],
'operand' => [\T_LNUMBER, '0'],
'replacement' => [\T_STRING, 'str_starts_with'],
'negate' => false,
],
[
'operator' => [\T_IS_NOT_IDENTICAL, '!=='],
'operand' => [\T_LNUMBER, '0'],
'replacement' => [\T_STRING, 'str_starts_with'],
'negate' => true,
],
[
'operator' => [\T_IS_NOT_IDENTICAL, '!=='],
'operand' => [\T_STRING, 'false'],
'replacement' => [\T_STRING, 'str_contains'],
'negate' => false,
],
[
'operator' => [\T_IS_IDENTICAL, '==='],
'operand' => [\T_STRING, 'false'],
'replacement' => [\T_STRING, 'str_contains'],
'negate' => true,
],
];
private bool $modernizeStripos = false;
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Replace `strpos()` and `stripos()` calls with `str_starts_with()` or `str_contains()` if possible.',
[
new CodeSample(
'<?php
if (strpos($haystack, $needle) === 0) {}
if (strpos($haystack, $needle) !== 0) {}
if (strpos($haystack, $needle) !== false) {}
if (strpos($haystack, $needle) === false) {}
',
),
new CodeSample(
'<?php
if (strpos($haystack, $needle) === 0) {}
if (strpos($haystack, $needle) !== 0) {}
if (strpos($haystack, $needle) !== false) {}
if (strpos($haystack, $needle) === false) {}
if (stripos($haystack, $needle) === 0) {}
if (stripos($haystack, $needle) !== 0) {}
if (stripos($haystack, $needle) !== false) {}
if (stripos($haystack, $needle) === false) {}
',
['modernize_stripos' => true]
),
],
null,
'Risky if `strpos`, `stripos`, `str_starts_with`, `str_contains` or `strtolower` functions are overridden.'
);
}
/**
* {@inheritdoc}
*
* Must run before BinaryOperatorSpacesFixer, NoExtraBlankLinesFixer, NoSpacesInsideParenthesisFixer, NoTrailingWhitespaceFixer, NotOperatorWithSpaceFixer, NotOperatorWithSuccessorSpaceFixer, PhpUnitDedicateAssertFixer, SingleSpaceAfterConstructFixer, SingleSpaceAroundConstructFixer, SpacesInsideParenthesesFixer.
* Must run after StrictComparisonFixer.
*/
public function getPriority(): int
{
return 37;
}
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(\T_STRING) && $tokens->isAnyTokenKindsFound([\T_IS_IDENTICAL, \T_IS_NOT_IDENTICAL]);
}
public function isRisky(): bool
{
return true;
}
protected function configurePostNormalisation(): void
{
if (isset($this->configuration['modernize_stripos']) && true === $this->configuration['modernize_stripos']) {
$this->modernizeStripos = true;
}
}
protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
{
return new FixerConfigurationResolver([
(new FixerOptionBuilder('modernize_stripos', 'Whether to modernize `stripos` calls as well.'))
->setAllowedTypes(['bool'])
->setDefault(false) // @TODO change to "true" on next major 4.0
->getOption(),
]);
}
protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
{
$functionsAnalyzer = new FunctionsAnalyzer();
$argumentsAnalyzer = new ArgumentsAnalyzer();
$modernizeCandidates = [[\T_STRING, 'strpos']];
if ($this->modernizeStripos) {
$modernizeCandidates[] = [\T_STRING, 'stripos'];
}
for ($index = \count($tokens) - 1; $index > 0; --$index) {
// find candidate function call
if (!$tokens[$index]->equalsAny($modernizeCandidates, false) || !$functionsAnalyzer->isGlobalFunctionCall($tokens, $index)) {
continue;
}
// assert called with 2 arguments
$openIndex = $tokens->getNextMeaningfulToken($index);
$closeIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openIndex);
$arguments = $argumentsAnalyzer->getArguments($tokens, $openIndex, $closeIndex);
if (2 !== \count($arguments)) {
continue;
}
// check if part condition and fix if needed
$compareTokens = $this->getCompareTokens($tokens, $index, -1); // look behind
if (null === $compareTokens) {
$compareTokens = $this->getCompareTokens($tokens, $closeIndex, 1); // look ahead
}
if (null !== $compareTokens) {
$isCaseInsensitive = $tokens[$index]->equals([\T_STRING, 'stripos'], false);
$this->fixCall($tokens, $index, $compareTokens, $isCaseInsensitive);
}
}
}
/**
* @param array{operator_index: int, operand_index: int} $operatorIndices
*/
private function fixCall(Tokens $tokens, int $functionIndex, array $operatorIndices, bool $isCaseInsensitive): void
{
foreach (self::REPLACEMENTS as $replacement) {
if (!$tokens[$operatorIndices['operator_index']]->equals($replacement['operator'])) {
continue;
}
if (!$tokens[$operatorIndices['operand_index']]->equals($replacement['operand'], false)) {
continue;
}
$tokens->clearTokenAndMergeSurroundingWhitespace($operatorIndices['operator_index']);
$tokens->clearTokenAndMergeSurroundingWhitespace($operatorIndices['operand_index']);
$tokens->clearTokenAndMergeSurroundingWhitespace($functionIndex);
if ($replacement['negate']) {
$negateInsertIndex = $functionIndex;
$prevFunctionIndex = $tokens->getPrevMeaningfulToken($functionIndex);
if ($tokens[$prevFunctionIndex]->isGivenKind(\T_NS_SEPARATOR)) {
$negateInsertIndex = $prevFunctionIndex;
}
$tokens->insertAt($negateInsertIndex, new Token('!'));
++$functionIndex;
}
$tokens->insertAt($functionIndex, new Token($replacement['replacement']));
if ($isCaseInsensitive) {
$this->wrapArgumentsWithStrToLower($tokens, $functionIndex);
}
break;
}
}
private function wrapArgumentsWithStrToLower(Tokens $tokens, int $functionIndex): void
{
$argumentsAnalyzer = new ArgumentsAnalyzer();
$shouldAddNamespace = $tokens[$functionIndex - 1]->isGivenKind(\T_NS_SEPARATOR);
$openIndex = $tokens->getNextMeaningfulToken($functionIndex);
$closeIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $openIndex);
$arguments = $argumentsAnalyzer->getArguments($tokens, $openIndex, $closeIndex);
$firstArgumentIndexStart = array_key_first($arguments);
if (!isset($arguments[$firstArgumentIndexStart])) {
return;
}
$firstArgumentIndexEnd = $arguments[$firstArgumentIndexStart] + 3 + ($shouldAddNamespace ? 1 : 0);
$isSecondArgumentTokenWhiteSpace = $tokens[array_key_last($arguments)]->isGivenKind(\T_WHITESPACE);
if ($isSecondArgumentTokenWhiteSpace) {
$secondArgumentIndexStart = $tokens->getNextMeaningfulToken(array_key_last($arguments));
} else {
$secondArgumentIndexStart = array_key_last($arguments);
}
$secondArgumentIndexStart += 3 + ($shouldAddNamespace ? 1 : 0);
if (!isset($arguments[array_key_last($arguments)])) {
return;
}
$secondArgumentIndexEnd = $arguments[array_key_last($arguments)] + 6 + ($shouldAddNamespace ? 1 : 0) + ($isSecondArgumentTokenWhiteSpace ? 1 : 0);
if ($shouldAddNamespace) {
$tokens->insertAt($firstArgumentIndexStart, new Token([\T_NS_SEPARATOR, '\\']));
++$firstArgumentIndexStart;
}
$tokens->insertAt($firstArgumentIndexStart, [new Token([\T_STRING, 'strtolower']), new Token('(')]);
$tokens->insertAt($firstArgumentIndexEnd, new Token(')'));
if ($shouldAddNamespace) {
$tokens->insertAt($secondArgumentIndexStart, new Token([\T_NS_SEPARATOR, '\\']));
++$secondArgumentIndexStart;
}
$tokens->insertAt($secondArgumentIndexStart, [new Token([\T_STRING, 'strtolower']), new Token('(')]);
$tokens->insertAt($secondArgumentIndexEnd, new Token(')'));
}
/**
* @param -1|1 $direction
*
* @return null|array{operator_index: int, operand_index: int}
*/
private function getCompareTokens(Tokens $tokens, int $offsetIndex, int $direction): ?array
{
$operatorIndex = $tokens->getMeaningfulTokenSibling($offsetIndex, $direction);
if (null !== $operatorIndex && $tokens[$operatorIndex]->isGivenKind(\T_NS_SEPARATOR)) {
$operatorIndex = $tokens->getMeaningfulTokenSibling($operatorIndex, $direction);
}
if (null === $operatorIndex || !$tokens[$operatorIndex]->isGivenKind([\T_IS_IDENTICAL, \T_IS_NOT_IDENTICAL])) {
return null;
}
$operandIndex = $tokens->getMeaningfulTokenSibling($operatorIndex, $direction);
if (null === $operandIndex) {
return null;
}
$operand = $tokens[$operandIndex];
if (!$operand->equals([\T_LNUMBER, '0']) && !$operand->equals([\T_STRING, 'false'], false)) {
return null;
}
$precedenceTokenIndex = $tokens->getMeaningfulTokenSibling($operandIndex, $direction);
if (null !== $precedenceTokenIndex && $this->isOfHigherPrecedence($tokens[$precedenceTokenIndex])) {
return null;
}
return ['operator_index' => $operatorIndex, 'operand_index' => $operandIndex];
}
private function isOfHigherPrecedence(Token $token): bool
{
return
$token->isGivenKind([
\T_DEC, // --
\T_INC, // ++
\T_INSTANCEOF, // instanceof
\T_IS_GREATER_OR_EQUAL, // >=
\T_IS_SMALLER_OR_EQUAL, // <=
\T_POW, // **
\T_SL, // <<
\T_SR, // >>
])
|| $token->equalsAny([
'!',
'%',
'*',
'+',
'-',
'.',
'/',
'<',
'>',
'~',
]);
}
}

Some files were not shown because too many files have changed in this diff Show More