├── stubs ├── PHPUnit │ ├── KernelTestCase.php │ └── PHPUnit_Framework_TestCase.php ├── Symfony │ └── Bundle │ │ └── FrameworkBundle │ │ └── Test │ │ └── KernelTestCase.php └── Behat │ └── Step │ └── Then.php ├── bin ├── swiss-knife └── swiss-knife.php ├── .github ├── FUNDING.yml └── workflows │ ├── bare_run.yaml │ ├── code_analysis.yaml │ └── downgraded_release.yaml ├── .editorconfig ├── templates └── .editorconfig ├── src ├── Exception │ ├── ShouldNotHappenException.php │ └── NotImplementedYetException.php ├── ValueObject │ ├── ClassConstantFetch │ │ ├── CurrentClassConstantFetch.php │ │ ├── ParentClassConstantFetch.php │ │ ├── StaticClassConstantFetch.php │ │ ├── ExternalClassAccessConstantFetch.php │ │ └── AbstractClassConstantFetch.php │ ├── ClassConstant.php │ ├── VisibilityChangeStats.php │ └── Traits │ │ ├── TraitMetadata.php │ │ └── TraitSpottingResult.php ├── FileSystem │ ├── JsonAnalyzer.php │ └── PathHelper.php ├── Enum │ ├── StaticAccessor.php │ ├── SymfonyClass.php │ └── SymfonyExtensionClass.php ├── Contract │ └── ClassConstantFetchInterface.php ├── Testing │ ├── PHPUnitMocker.php │ ├── Printer │ │ └── PHPUnitXmlPrinter.php │ ├── UnitTestFilePathsFinder.php │ ├── Finder │ │ └── TestCaseClassFinder.php │ ├── UnitTestFilter.php │ ├── Command │ │ └── DetectUnitTestsCommand.php │ └── MockWire.php ├── PhpParser │ ├── NodeTraverserFactory.php │ ├── Finder │ │ ├── ClassConstFinder.php │ │ └── ClassConstantFetchFinder.php │ ├── NodeVisitor │ │ ├── AddImportConfigMethodCallNodeVisitor.php │ │ ├── ExtractSymfonyExtensionCallNodeVisitor.php │ │ ├── NeedForFinalizeNodeVisitor.php │ │ ├── ParentClassNameCollectingNodeVisitor.php │ │ ├── FindNonPrivateClassConstNodeVisitor.php │ │ ├── MockedClassNameCollectingNodeVisitor.php │ │ ├── EntityClassNameCollectingNodeVisitor.php │ │ └── FindClassConstFetchNodeVisitor.php │ ├── NodeFactory │ │ └── SplitConfigClosureFactory.php │ └── CachedPhpParser.php ├── RobotLoader │ └── PhpClassLoader.php ├── Finder │ ├── MultipleClassInOneFileFinder.php │ ├── TraitFilesFinder.php │ ├── PhpFilesFinder.php │ └── FilesFinder.php ├── Command │ ├── DumpEditorconfigCommand.php │ ├── CheckConflictsCommand.php │ ├── FindMultiClassesCommand.php │ ├── SearchRegexCommand.php │ ├── CheckCommentedCodeCommand.php │ ├── AliceYamlFixturesToPhpCommand.php │ ├── PrettyJsonCommand.php │ ├── SpotLazyTraitsCommand.php │ ├── GenerateSymfonyConfigBuildersCommand.php │ ├── NamespaceToPSR4Command.php │ ├── SplitSymfonyConfigToPerPackageCommand.php │ ├── FinalizeClassesCommand.php │ └── PrivatizeConstantsCommand.php ├── Analyzer │ └── NeedsFinalizeAnalyzer.php ├── Comments │ └── CommentedCodeAnalyzer.php ├── Git │ └── ConflictResolver.php ├── ParentClassResolver.php ├── YAML │ └── YamlConfigConstantExtractor.php ├── MockedClassResolver.php ├── Twig │ └── TwigTemplateConstantExtractor.php ├── Traits │ └── TraitSpotter.php ├── DependencyInjection │ └── ContainerFactory.php └── EntityClassResolver.php ├── ecs.php ├── phpunit.xml ├── full-tool-build.sh ├── rector.php ├── composer-dependency-analyser.php ├── LICENSE ├── phpstan.neon ├── prefix-code.sh ├── composer.json └── scoper.php /stubs/PHPUnit/KernelTestCase.php: -------------------------------------------------------------------------------- 1 | = 3; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Enum/StaticAccessor.php: -------------------------------------------------------------------------------- 1 | withSkip([ 9 | // invalid syntax test fixture 10 | __DIR__ . '/tests/PhpParser/Finder/ClassConstantFetchFinder/Fixture/Error/ParseError.php', 11 | ]) 12 | ->withPreparedSets(psr12: true, common: true, symplify: true) 13 | ->withPaths([__DIR__ . '/src', __DIR__ . '/tests']) 14 | ->withRootFiles(); 15 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | tests 12 | tests/Testing/UnitTestFilePathsFinder/Fixture 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Testing/PHPUnitMocker.php: -------------------------------------------------------------------------------- 1 | createMock($classObject); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /full-tool-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # add patches 4 | composer install --ansi 5 | 6 | # but skip dev dependencies 7 | composer update --no-dev --ansi 8 | 9 | # remove tests and useless files, to make downgraded, scoped and deployed codebase as small as possible 10 | rm -rf tests 11 | 12 | # downgrade with rector 13 | mkdir rector-local 14 | composer require rector/rector --working-dir rector-local 15 | rector-local/vendor/bin/rector process bin src vendor --config build/rector-downgrade-php-72.php --ansi 16 | 17 | # prefix 18 | sh prefix-code.sh 19 | -------------------------------------------------------------------------------- /src/PhpParser/NodeTraverserFactory.php: -------------------------------------------------------------------------------- 1 | addVisitor($nodeVisitor); 19 | 20 | return $nodeTraverser; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/FileSystem/PathHelper.php: -------------------------------------------------------------------------------- 1 | withPaths([__DIR__ . '/src', __DIR__ . '/tests']) 9 | ->withPhpSets() 10 | ->withPreparedSets( 11 | codeQuality: true, 12 | deadCode: true, 13 | typeDeclarations: true, 14 | privatization: true, 15 | earlyReturn: true, 16 | codingStyle: true, 17 | instanceOf: true, 18 | naming: true 19 | ) 20 | ->withImportNames(removeUnusedImports: true) 21 | ->withSkip(['*/scoper.php', '*/Source/*', '*/Fixture/*']); 22 | -------------------------------------------------------------------------------- /src/ValueObject/ClassConstant.php: -------------------------------------------------------------------------------- 1 | className; 25 | } 26 | 27 | public function getConstantName(): string 28 | { 29 | return $this->constantName; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ValueObject/VisibilityChangeStats.php: -------------------------------------------------------------------------------- 1 | privateCount; 14 | } 15 | 16 | public function getPrivateCount(): int 17 | { 18 | return $this->privateCount; 19 | } 20 | 21 | public function merge(self $currentVisibilityChangeStats): void 22 | { 23 | $this->privateCount += $currentVisibilityChangeStats->getPrivateCount(); 24 | } 25 | 26 | public function hasAnyChange(): bool 27 | { 28 | return $this->privateCount > 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/RobotLoader/PhpClassLoader.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function load(array $directories, array $excludedPaths): array 15 | { 16 | $robotLoader = new RobotLoader(); 17 | $robotLoader->addDirectory(...$directories); 18 | $robotLoader->excludeDirectory(...$excludedPaths); 19 | 20 | $robotLoader->setTempDirectory(sys_get_temp_dir() . '/multiple-classes'); 21 | $robotLoader->rebuild(); 22 | 23 | return $robotLoader->getIndexedClasses(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Testing/Printer/PHPUnitXmlPrinter.php: -------------------------------------------------------------------------------- 1 | elements in https://phpunit.readthedocs.io/en/9.5/configuration.html#the-testsuite-element 13 | * 14 | * @param string[] $filePaths 15 | */ 16 | public function printFiles(array $filePaths): string 17 | { 18 | $fileContents = ''; 19 | foreach ($filePaths as $filePath) { 20 | $relativeFilePath = PathHelper::relativeToCwd($filePath); 21 | $fileContents .= '' . $relativeFilePath . '' . PHP_EOL; 22 | } 23 | 24 | return $fileContents; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /composer-dependency-analyser.php: -------------------------------------------------------------------------------- 1 | ignoreErrorsOnPackage('phpunit/phpunit', [ErrorType::DEV_DEPENDENCY_IN_PROD]) 12 | 13 | // optional dependency for symfony config generator command 14 | ->ignoreErrorsOnPackage('symfony/config', [ErrorType::DEV_DEPENDENCY_IN_PROD]) 15 | ->ignoreErrorsOnPackage('symfony/dependency-injection', [ErrorType::DEV_DEPENDENCY_IN_PROD]) 16 | 17 | // test fixture 18 | ->ignoreErrorsOnPath( 19 | __DIR__ . '/tests/EntityClassResolver/Fixture/Anything/SomeAttributeDocument.php', 20 | [ErrorType::UNKNOWN_CLASS] 21 | ); 22 | -------------------------------------------------------------------------------- /src/Testing/UnitTestFilePathsFinder.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public function findInDirectories(array $directories): array 25 | { 26 | $testsCasesClassesToFilePaths = $this->testCaseClassFinder->findInDirectories($directories); 27 | 28 | return $this->unitTestFilter->filter($testsCasesClassesToFilePaths); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Finder/MultipleClassInOneFileFinder.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | public function findInDirectories(array $directories, array $excludedPaths): array 22 | { 23 | $fileByClasses = $this->phpClassLoader->load($directories, $excludedPaths); 24 | 25 | $classesByFile = []; 26 | foreach ($fileByClasses as $class => $filePath) { 27 | $classesByFile[$filePath][] = $class; 28 | } 29 | 30 | return array_filter($classesByFile, static fn (array $classes): bool => count($classes) >= 2); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ValueObject/ClassConstantFetch/AbstractClassConstantFetch.php: -------------------------------------------------------------------------------- 1 | className; 21 | } 22 | 23 | public function getConstantName(): string 24 | { 25 | return $this->constantName; 26 | } 27 | 28 | public function isClassConstantMatch(ClassConstant $classConstant): bool 29 | { 30 | if ($classConstant->getClassName() !== $this->className) { 31 | return false; 32 | } 33 | 34 | return $classConstant->getConstantName() === $this->constantName; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | --------------- 3 | 4 | Copyright (c) 2020 Tomas Votruba (https://tomasvotruba.com) 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/PhpParser/Finder/ClassConstFinder.php: -------------------------------------------------------------------------------- 1 | cachedPhpParser->parseFile($filePath); 31 | $nodeTraverser->traverse($fileStmts); 32 | 33 | return $findNonPrivateClassConstNodeVisitor->getClassConstants(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /bin/swiss-knife.php: -------------------------------------------------------------------------------- 1 | create(); 34 | 35 | $application = $container->make(Application::class); 36 | 37 | $exitCode = $application->run(new ArgvInput(), new ConsoleOutput()); 38 | exit($exitCode); 39 | -------------------------------------------------------------------------------- /src/Testing/Finder/TestCaseClassFinder.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function findInDirectories(array $directories): array 16 | { 17 | $robotLoader = new RobotLoader(); 18 | $robotLoader->addDirectory(...$directories); 19 | $robotLoader->rebuild(); 20 | 21 | $this->includeNonAutoloadedClasses($robotLoader->getIndexedClasses()); 22 | 23 | return $robotLoader->getIndexedClasses(); 24 | } 25 | 26 | /** 27 | * @param array $classesToFilePaths 28 | */ 29 | private function includeNonAutoloadedClasses(array $classesToFilePaths): void 30 | { 31 | foreach ($classesToFilePaths as $class => $filePath) { 32 | if (class_exists($class)) { 33 | continue; 34 | } 35 | 36 | if (interface_exists($class)) { 37 | continue; 38 | } 39 | 40 | if (trait_exists($class)) { 41 | continue; 42 | } 43 | 44 | require_once $filePath; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ValueObject/Traits/TraitMetadata.php: -------------------------------------------------------------------------------- 1 | lineCount = substr_count(FileSystem::read($filePath), PHP_EOL); 23 | } 24 | 25 | public function getShortTraitName(): string 26 | { 27 | return $this->shortTraitName; 28 | } 29 | 30 | public function getFilePath(): string 31 | { 32 | return $this->filePath; 33 | } 34 | 35 | public function markUsedIn(string $filePath): void 36 | { 37 | $this->usedIn[] = $filePath; 38 | } 39 | 40 | /** 41 | * @return string[] 42 | */ 43 | public function getUsedIn(): array 44 | { 45 | return $this->usedIn; 46 | } 47 | 48 | public function getUsedInCount(): int 49 | { 50 | return count($this->usedIn); 51 | } 52 | 53 | public function getLineCount(): int 54 | { 55 | return $this->lineCount; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | 4 | paths: 5 | - src 6 | - tests 7 | 8 | treatPhpDocTypesAsCertain: false 9 | errorFormat: symplify 10 | 11 | excludePaths: 12 | - */Fixture/* 13 | - */Source/* 14 | 15 | ignoreErrors: 16 | # unrelated 17 | - '#Parameter \#1 \$className of class Rector\\SwissKnife\\ValueObject\\ClassConstant constructor expects class-string, string given#' 18 | 19 | - '#Parameter \#1 \$objectOrClass of class ReflectionClass constructor expects class-string\|T of object, string given#' 20 | 21 | # command status enum 22 | - 23 | identifier: return.unusedType 24 | path: src/Command/ 25 | 26 | # testing on purpose 27 | - 28 | identifier: method.alreadyNarrowedType 29 | path: tests 30 | 31 | # compatibility with PHPUnit 9 and 10 32 | - 33 | identifier: arguments.count 34 | path: src/Testing/MockWire.php 35 | 36 | # type from service definition 37 | - 38 | identifier: argument.type 39 | path: src/Testing/MockWire.php 40 | 41 | # optional command, depends on present of classes 42 | - 43 | identifier: argument.unresolvableType 44 | path: src/Command/GenerateSymfonyConfigBuildersCommand.php 45 | -------------------------------------------------------------------------------- /src/Command/DumpEditorconfigCommand.php: -------------------------------------------------------------------------------- 1 | setName('dump-editorconfig'); 24 | $this->setDescription('Dump .editorconfig file to project root'); 25 | } 26 | 27 | protected function execute(InputInterface $input, OutputInterface $output): int 28 | { 29 | $projectEditorconfigFilePath = getcwd() . '/.editorconfig'; 30 | if (file_exists($projectEditorconfigFilePath)) { 31 | $this->symfonyStyle->error('.editorconfig file already exists'); 32 | return self::FAILURE; 33 | } 34 | 35 | FileSystem::copy(__DIR__ . '/../../templates/.editorconfig', $projectEditorconfigFilePath); 36 | $this->symfonyStyle->success('.editorconfig file was created'); 37 | 38 | return self::SUCCESS; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Testing/UnitTestFilter.php: -------------------------------------------------------------------------------- 1 | [] 11 | */ 12 | private const NON_UNIT_TEST_CASE_CLASSES = [ 13 | 'Symfony\Bundle\FrameworkBundle\Test\KernelTestCase', 14 | 'Symfony\Component\Form\Test\TypeTestCase', 15 | ]; 16 | 17 | /** 18 | * @param array $testClassesToFilePaths 19 | * @return array 20 | */ 21 | public function filter(array $testClassesToFilePaths): array 22 | { 23 | return array_filter( 24 | $testClassesToFilePaths, 25 | fn (string $testClass): bool => $this->isUnitTest($testClass), 26 | ARRAY_FILTER_USE_KEY 27 | ); 28 | } 29 | 30 | private function isUnitTest(string $class): bool 31 | { 32 | if (! is_a($class, 'PHPUnit\Framework\TestCase', true) && ! is_a($class, 'PHPUnit_Framework_TestCase', true)) { 33 | return false; 34 | } 35 | 36 | foreach (self::NON_UNIT_TEST_CASE_CLASSES as $nonUnitTestCaseClass) { 37 | // required special behavior 38 | if (is_a($class, $nonUnitTestCaseClass, true)) { 39 | return false; 40 | } 41 | } 42 | 43 | return true; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Analyzer/NeedsFinalizeAnalyzer.php: -------------------------------------------------------------------------------- 1 | needForFinalizeNodeVisitor = new NeedForFinalizeNodeVisitor($excludedClasses); 29 | $finalizingNodeTraverser = NodeTraverserFactory::create($this->needForFinalizeNodeVisitor); 30 | 31 | $this->finalizingNodeTraverser = $finalizingNodeTraverser; 32 | } 33 | 34 | public function isNeeded(string $filePath): bool 35 | { 36 | $stmts = $this->cachedPhpParser->parseFile($filePath); 37 | $this->finalizingNodeTraverser->traverse($stmts); 38 | 39 | return $this->needForFinalizeNodeVisitor->isNeeded(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Enum/SymfonyExtensionClass.php: -------------------------------------------------------------------------------- 1 | params) !== 1) { 32 | return null; 33 | } 34 | 35 | $configDirectoryPathString = new String_($this->outputDirectory . '/*'); 36 | $concat = new Concat(new Dir(), $configDirectoryPathString); 37 | 38 | $importMethodCall = new MethodCall(new Variable('containerConfigurator'), 'import', [new Arg($concat)]); 39 | $node->stmts[] = new Expression($importMethodCall); 40 | 41 | return self::STOP_TRAVERSAL; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /prefix-code.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # inspired from https://github.com/rectorphp/rector/blob/main/build/build-rector-scoped.sh 4 | 5 | # see https://stackoverflow.com/questions/66644233/how-to-propagate-colors-from-bash-script-to-github-action?noredirect=1#comment117811853_66644233 6 | export TERM=xterm-color 7 | 8 | # show errors 9 | set -e 10 | 11 | # script fails if trying to access to an undefined variable 12 | set -u 13 | 14 | 15 | # functions 16 | note() 17 | { 18 | MESSAGE=$1; 19 | printf "\n"; 20 | echo "\033[0;33m[NOTE] $MESSAGE\033[0m"; 21 | } 22 | 23 | # --------------------------- 24 | 25 | # 2. scope it 26 | note "Downloading php-scoper 0.18.11" 27 | wget https://github.com/humbug/php-scoper/releases/download/0.18.11/php-scoper.phar -N --no-verbose 28 | 29 | 30 | note "Running php-scoper" 31 | 32 | # Work around possible PHP memory limits 33 | php -d memory_limit=-1 php-scoper.phar add-prefix bin src vendor composer.json --config scoper.php --force --ansi --output-dir scoped-code 34 | 35 | # the output code is in "/scoped-code", lets move it up 36 | # the local directories have to be empty to move easily 37 | rm -r bin src vendor composer.json stubs 38 | mv scoped-code/* . 39 | 40 | note "Dumping Composer Autoload" 41 | composer dump-autoload --ansi --classmap-authoritative --no-dev 42 | 43 | # make bin/ecs runnable without "php" 44 | chmod 777 "bin/swiss-knife" 45 | chmod 777 "bin/swiss-knife.php" 46 | 47 | note "Finished" 48 | -------------------------------------------------------------------------------- /src/Comments/CommentedCodeAnalyzer.php: -------------------------------------------------------------------------------- 1 | $fileLine) { 34 | $isCommentLine = str_starts_with(trim((string) $fileLine), '//'); 35 | if ($isCommentLine) { 36 | ++$commentLinesCount; 37 | } else { 38 | // crossed the threshold? 39 | if ($commentLinesCount >= $commentedLinesCountLimit) { 40 | $commentedLines[] = $key; 41 | } 42 | 43 | // reset counter 44 | $commentLinesCount = 0; 45 | } 46 | } 47 | 48 | return $commentedLines; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/PhpParser/NodeFactory/SplitConfigClosureFactory.php: -------------------------------------------------------------------------------- 1 | stmts[] = new Expression($extensionMethodCall); 33 | $closure->returnType = new Identifier('void'); 34 | $closure->params[] = new Param(new Variable('containerConfigurator'), null, new FullyQualified( 35 | SymfonyClass::CONTAINER_CONFIGURATOR_CLASS 36 | )); 37 | 38 | $return = new Return_($closure); 39 | 40 | return [$strictTypesDeclare, new Nop(), $return]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/PhpParser/NodeVisitor/ExtractSymfonyExtensionCallNodeVisitor.php: -------------------------------------------------------------------------------- 1 | expr instanceof MethodCall) { 32 | return null; 33 | } 34 | 35 | $methodCall = $node->expr; 36 | if (! $methodCall->name instanceof Identifier) { 37 | return null; 38 | } 39 | 40 | $methodName = $methodCall->name->toString(); 41 | if ($methodName !== self::EXTENSION_METHOD_NAME) { 42 | return null; 43 | } 44 | 45 | $this->extensionMethodCalls[] = $methodCall; 46 | 47 | return self::REMOVE_NODE; 48 | } 49 | 50 | /** 51 | * @return MethodCall[] 52 | */ 53 | public function getExtensionMethodCalls(): array 54 | { 55 | return $this->extensionMethodCalls; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Git/ConflictResolver.php: -------------------------------------------------------------------------------- 1 | >>>>>>)#m'; 20 | 21 | /** 22 | * @api 23 | */ 24 | public function extractFromFileInfo(string $filePath): int 25 | { 26 | $fileContents = FileSystem::read($filePath); 27 | $conflictsMatch = Strings::matchAll($fileContents, self::CONFLICT_REGEX); 28 | 29 | return count($conflictsMatch); 30 | } 31 | 32 | /** 33 | * @param string[] $filePaths 34 | * @return int[] 35 | */ 36 | public function extractFromFileInfos(array $filePaths): array 37 | { 38 | $conflictCountsByFilePath = []; 39 | 40 | foreach ($filePaths as $filePath) { 41 | $conflictCount = $this->extractFromFileInfo($filePath); 42 | if ($conflictCount === 0) { 43 | continue; 44 | } 45 | 46 | // test fixtures, that should be ignored 47 | if (str_contains((string) realpath($filePath), '/tests/Git/ConflictResolver/Fixture')) { 48 | continue; 49 | } 50 | 51 | $conflictCountsByFilePath[$filePath] = $conflictCount; 52 | } 53 | 54 | return $conflictCountsByFilePath; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/PhpParser/CachedPhpParser.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | private array $cachedStmts = []; 23 | 24 | public function __construct( 25 | private readonly Parser $phpParser 26 | ) { 27 | } 28 | 29 | /** 30 | * @return Stmt[] 31 | */ 32 | public function parseFile(string $filePath): array 33 | { 34 | if (isset($this->cachedStmts[$filePath])) { 35 | return $this->cachedStmts[$filePath]; 36 | } 37 | 38 | $fileContents = FileSystem::read($filePath); 39 | try { 40 | $stmts = $this->phpParser->parse($fileContents); 41 | } catch (Throwable $throwable) { 42 | throw new RuntimeException(sprintf( 43 | 'Could not parse file "%s": %s', 44 | $filePath, 45 | $throwable->getMessage() 46 | ), $throwable->getCode(), $throwable); 47 | } 48 | 49 | if (is_array($stmts)) { 50 | $nodeTraverser = NodeTraverserFactory::create(new NameResolver()); 51 | $nodeTraverser->traverse($stmts); 52 | } 53 | 54 | $this->cachedStmts[$filePath] = $stmts ?? []; 55 | 56 | return $stmts ?? []; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ValueObject/Traits/TraitSpottingResult.php: -------------------------------------------------------------------------------- 1 | traitsMetadatas); 20 | } 21 | 22 | /** 23 | * @return TraitMetadata[] 24 | */ 25 | public function getTraitMaximumUsedTimes(int $limit): array 26 | { 27 | $usedTraitsMetadatas = []; 28 | 29 | foreach ($this->traitsMetadatas as $traitMetadata) { 30 | // not used at all, already handled by phpstan 31 | if ($traitMetadata->getUsedInCount() === 0) { 32 | continue; 33 | } 34 | 35 | // to many places 36 | if ($traitMetadata->getUsedInCount() > $limit) { 37 | continue; 38 | } 39 | 40 | $usedTraitsMetadatas[] = $traitMetadata; 41 | } 42 | 43 | return $usedTraitsMetadatas; 44 | } 45 | 46 | /** 47 | * @return string[] 48 | */ 49 | public function getTraitFilePaths(): array 50 | { 51 | $traitFilePaths = []; 52 | 53 | foreach ($this->traitsMetadatas as $traitMetadata) { 54 | $traitFilePaths[] = $traitMetadata->getFilePath(); 55 | } 56 | 57 | sort($traitFilePaths); 58 | 59 | return $traitFilePaths; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ParentClassResolver.php: -------------------------------------------------------------------------------- 1 | traverseFileInfos($phpFileInfos, $nodeTraverser, $progressClosure); 30 | 31 | return $parentClassNameCollectingNodeVisitor->getParentClassNames(); 32 | } 33 | 34 | /** 35 | * @param SplFileInfo[] $phpFileInfos 36 | */ 37 | private function traverseFileInfos( 38 | array $phpFileInfos, 39 | NodeTraverser $nodeTraverser, 40 | callable $progressClosure 41 | ): void { 42 | foreach ($phpFileInfos as $phpFileInfo) { 43 | $stmts = $this->cachedPhpParser->parseFile($phpFileInfo->getRealPath()); 44 | 45 | $nodeTraverser->traverse($stmts); 46 | $progressClosure(); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/PhpParser/NodeVisitor/NeedForFinalizeNodeVisitor.php: -------------------------------------------------------------------------------- 1 | isNeeded = false; 32 | 33 | return $nodes; 34 | } 35 | 36 | public function enterNode(Node $node): ?Node 37 | { 38 | if (! $node instanceof Class_) { 39 | return null; 40 | } 41 | 42 | // nothing we can do 43 | if ($node->isFinal() || $node->isAnonymous() || $node->isAbstract()) { 44 | return null; 45 | } 46 | 47 | // we need a name to make it work 48 | if (! $node->namespacedName instanceof Name) { 49 | return null; 50 | } 51 | 52 | $className = $node->namespacedName->toString(); 53 | if (in_array($className, $this->excludedClasses, true)) { 54 | return null; 55 | } 56 | 57 | $this->isNeeded = true; 58 | 59 | return $node; 60 | } 61 | 62 | public function isNeeded(): bool 63 | { 64 | return $this->isNeeded; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Finder/TraitFilesFinder.php: -------------------------------------------------------------------------------- 1 | files() 24 | ->in($directories) 25 | ->name('*.php') 26 | ->sortByName() 27 | ->filter(function (SplFileInfo $fileInfo): bool { 28 | $fileContent = $fileInfo->getContents(); 29 | return str_contains($fileContent, ' use '); 30 | }); 31 | 32 | return iterator_to_array($traitUsersFinder->getIterator()); 33 | } 34 | 35 | /** 36 | * @param string[] $directories 37 | * @return array 38 | */ 39 | public function find(array $directories): array 40 | { 41 | Assert::allString($directories); 42 | 43 | $traitFinder = Finder::create() 44 | ->files() 45 | ->in($directories) 46 | ->name('*.php') 47 | ->notPath('Entity') 48 | ->notPath('Document') 49 | ->sortByName() 50 | ->filter(function (SplFileInfo $fileInfo): bool { 51 | $fileContent = $fileInfo->getContents(); 52 | return (bool) Strings::match($fileContent, '#^trait\s#m'); 53 | }); 54 | 55 | return iterator_to_array($traitFinder->getIterator()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/PhpParser/NodeVisitor/ParentClassNameCollectingNodeVisitor.php: -------------------------------------------------------------------------------- 1 | extends instanceof Name) { 26 | return null; 27 | } 28 | 29 | $this->parentClassNames[] = $node->extends->toString(); 30 | 31 | return $node; 32 | } 33 | 34 | /** 35 | * @return string[] 36 | */ 37 | public function getParentClassNames(): array 38 | { 39 | $uniqueParentClassNames = array_unique($this->parentClassNames); 40 | sort($uniqueParentClassNames); 41 | 42 | // remove native classes 43 | $namespacedClassNames = array_filter( 44 | $uniqueParentClassNames, 45 | static fn (string $parentClassName): bool => str_contains($parentClassName, '\\') 46 | ); 47 | 48 | // remove obviously vendor names 49 | $namespacedClassNames = array_filter($namespacedClassNames, static function (string $className): bool { 50 | if (str_contains($className, 'Symfony\\')) { 51 | return false; 52 | } 53 | 54 | if (str_contains($className, 'PHPStan\\')) { 55 | return false; 56 | } 57 | 58 | return ! str_contains($className, 'PhpParser\\'); 59 | }); 60 | 61 | return array_values($namespacedClassNames); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/YAML/YamlConfigConstantExtractor.php: -------------------------------------------------------------------------------- 1 | findClassConstantFetchesInFile($yamlFileInfo->getRealPath()); 29 | 30 | $classConstantFetches = array_merge($classConstantFetches, $currentClassConstantFetches); 31 | } 32 | 33 | return $classConstantFetches; 34 | } 35 | 36 | /** 37 | * @return ClassConstantFetchInterface[] 38 | */ 39 | private function findClassConstantFetchesInFile(string $filePath): array 40 | { 41 | $fileContents = FileSystem::read($filePath); 42 | 43 | // find constant fetches in YAML file 44 | $constantMatches = Strings::matchAll($fileContents, '#\b(?[\w\\\]+)::(?\w+)\b#'); 45 | 46 | $externalClassAccessConstantFetches = []; 47 | foreach ($constantMatches as $constantMatch) { 48 | $externalClassAccessConstantFetches[] = new ExternalClassAccessConstantFetch( 49 | $constantMatch['class'], 50 | $constantMatch['constant'] 51 | ); 52 | } 53 | 54 | return $externalClassAccessConstantFetches; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/code_analysis.yaml: -------------------------------------------------------------------------------- 1 | name: Code Analysis 2 | 3 | on: 4 | pull_request: null 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | code_analysis: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | actions: 15 | - 16 | name: 'PHPStan' 17 | run: composer phpstan --ansi 18 | 19 | - 20 | name: 'Composer Validate' 21 | run: composer validate --ansi 22 | 23 | - 24 | name: 'Rector' 25 | run: composer rector --ansi 26 | 27 | - 28 | name: 'Coding Standard' 29 | run: composer fix-cs --ansi 30 | 31 | - 32 | name: 'Tests' 33 | run: vendor/bin/phpunit 34 | 35 | - 36 | name: 'Check Commented Code' 37 | run: bin/swiss-knife check-commented-code src tests --ansi 38 | 39 | - 40 | name: 'Check Active Classes' 41 | run: vendor/bin/class-leak check bin src --ansi 42 | 43 | - 44 | name: 'Unusued check' 45 | run: vendor/bin/composer-dependency-analyser 46 | 47 | name: ${{ matrix.actions.name }} 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | - uses: actions/checkout@v3 52 | # see https://github.com/shivammathur/setup-php 53 | - uses: shivammathur/setup-php@v2 54 | with: 55 | php-version: 8.2 56 | coverage: none 57 | 58 | # composer install cache - https://github.com/ramsey/composer-install 59 | - uses: "ramsey/composer-install@v2" 60 | 61 | - run: ${{ matrix.actions.run }} 62 | -------------------------------------------------------------------------------- /src/MockedClassResolver.php: -------------------------------------------------------------------------------- 1 | traverseFileInfos($phpFileInfos, $nodeTraverser, $progressClosure); 36 | 37 | $mockedClassNames = $mockedClassNameCollectingNodeVisitor->getMockedClassNames(); 38 | sort($mockedClassNames); 39 | 40 | return array_unique($mockedClassNames); 41 | } 42 | 43 | /** 44 | * @param SplFileInfo[] $phpFileInfos 45 | */ 46 | private function traverseFileInfos( 47 | array $phpFileInfos, 48 | NodeTraverser $nodeTraverser, 49 | ?callable $progressClosure = null 50 | ): void { 51 | foreach ($phpFileInfos as $phpFileInfo) { 52 | $stmts = $this->cachedPhpParser->parseFile($phpFileInfo->getRealPath()); 53 | 54 | $nodeTraverser->traverse($stmts); 55 | 56 | if (is_callable($progressClosure)) { 57 | $progressClosure(); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rector/swiss-knife", 3 | "description": "Swiss knife in pocket of every upgrade architect", 4 | "license": "MIT", 5 | "bin": [ 6 | "bin/swiss-knife" 7 | ], 8 | "require": { 9 | "php": ">=8.2", 10 | "illuminate/container": "^12.19", 11 | "nette/robot-loader": "^4.0", 12 | "nette/utils": "^4.0", 13 | "nikic/php-parser": "^5.5", 14 | "symfony/console": "^6.4.24", 15 | "symfony/finder": "^7.3", 16 | "symfony/yaml": "^7.3", 17 | "webmozart/assert": "^1.11" 18 | }, 19 | "require-dev": { 20 | "phpecs/phpecs": "^2.1", 21 | "phpstan/extension-installer": "^1.4", 22 | "phpstan/phpstan": "^2.1", 23 | "phpunit/phpunit": "^11.5", 24 | "rector/jack": "^0.2.3", 25 | "rector/rector": "^2.1", 26 | "shipmonk/composer-dependency-analyser": "^1.8", 27 | "symfony/config": "^6.4", 28 | "symfony/dependency-injection": "^6.4", 29 | "symplify/phpstan-extensions": "^12.0", 30 | "symplify/vendor-patches": "^11.4", 31 | "tomasvotruba/class-leak": "^2.0", 32 | "tomasvotruba/unused-public": "^2.0", 33 | "tracy/tracy": "^2.10" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Rector\\SwissKnife\\": "src" 38 | }, 39 | "classmap": [ 40 | "stubs" 41 | ] 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Rector\\SwissKnife\\Tests\\": "tests" 46 | } 47 | }, 48 | "config": { 49 | "sort-packages": true, 50 | "platform-check": false, 51 | "allow-plugins": { 52 | "cweagans/composer-patches": true, 53 | "phpstan/extension-installer": true 54 | } 55 | }, 56 | "scripts": { 57 | "check-cs": "vendor/bin/ecs check --ansi", 58 | "fix-cs": "vendor/bin/ecs check --fix --ansi", 59 | "phpstan": "vendor/bin/phpstan analyse --ansi", 60 | "rector": "vendor/bin/rector process --ansi" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Command/CheckConflictsCommand.php: -------------------------------------------------------------------------------- 1 | setName('check-conflicts'); 27 | 28 | $this->setDescription('Check files for missed git conflicts'); 29 | $this->addArgument('sources', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Path to project'); 30 | } 31 | 32 | protected function execute(InputInterface $input, OutputInterface $output): int 33 | { 34 | /** @var string[] $sources */ 35 | $sources = (array) $input->getArgument('sources'); 36 | 37 | $fileInfos = FilesFinder::find($sources); 38 | $filePaths = []; 39 | foreach ($fileInfos as $fileInfo) { 40 | $filePaths[] = $fileInfo->getRealPath(); 41 | } 42 | 43 | $conflictsCountByFilePath = $this->conflictResolver->extractFromFileInfos($filePaths); 44 | if ($conflictsCountByFilePath === []) { 45 | $message = sprintf('No conflicts found in %d files', count($fileInfos)); 46 | $this->symfonyStyle->success($message); 47 | 48 | return self::SUCCESS; 49 | } 50 | 51 | foreach ($conflictsCountByFilePath as $file => $conflictCount) { 52 | $message = sprintf('File "%s" contains %d unresolved conflicts', $file, $conflictCount); 53 | $this->symfonyStyle->error($message); 54 | } 55 | 56 | return self::FAILURE; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Twig/TwigTemplateConstantExtractor.php: -------------------------------------------------------------------------------- 1 | findClassConstantFetchesInFile($twigFileInfo->getRealPath()); 29 | 30 | $classConstantFetches = array_merge($classConstantFetches, $currentClassConstantFetches); 31 | } 32 | 33 | return $classConstantFetches; 34 | } 35 | 36 | /** 37 | * @return ClassConstantFetchInterface[] 38 | */ 39 | private function findClassConstantFetchesInFile(string $filePath): array 40 | { 41 | $fileContents = FileSystem::read($filePath); 42 | 43 | $constantMatches = Strings::matchAll($fileContents, '#{{.*?\s*constant\(\s*([\'"])(?.*?)\1#'); 44 | 45 | $externalClassAccessConstantFetches = []; 46 | foreach ($constantMatches as $constantMatch) { 47 | $constantMatchValue = $constantMatch['constant']; 48 | 49 | // global constant → skip 50 | if (! str_contains((string) $constantMatchValue, '::')) { 51 | continue; 52 | } 53 | 54 | [$className, $constantName] = explode('::', (string) $constantMatchValue); 55 | $className = str_replace('\\\\', '\\', $className); 56 | 57 | $externalClassAccessConstantFetches[] = new ExternalClassAccessConstantFetch($className, $constantName); 58 | } 59 | 60 | return $externalClassAccessConstantFetches; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Finder/PhpFilesFinder.php: -------------------------------------------------------------------------------- 1 | getIterator()); 24 | } 25 | 26 | /** 27 | * @param string[] $paths 28 | * @param string[] $excludedPaths 29 | */ 30 | private static function createFinderForPathsAndExcludedPaths(array $paths, array $excludedPaths): Finder 31 | { 32 | Assert::allString($paths); 33 | Assert::allFileExists($paths); 34 | 35 | Assert::allString($excludedPaths); 36 | $excludedFileNames = []; 37 | foreach ($excludedPaths as $excludedPath) { 38 | if (! str_contains($excludedPath, '*')) { 39 | $excludedFileNames[] = $excludedPath; 40 | } 41 | } 42 | 43 | Assert::allFileExists($excludedFileNames); 44 | 45 | return Finder::create() 46 | ->files() 47 | ->in($paths) 48 | ->name('*.php') 49 | ->notPath('vendor') 50 | ->notPath('var') 51 | ->notPath('data-fixtures') 52 | ->notPath('node_modules') 53 | // exclude paths, as notPaths() does no work 54 | ->filter(static function (SplFileInfo $splFileInfo) use ($excludedPaths): bool { 55 | foreach ($excludedPaths as $excludedPath) { 56 | $realpath = $splFileInfo->getRealPath(); 57 | if (str_contains($realpath, $excludedPath)) { 58 | return false; 59 | } 60 | 61 | if (str_contains($excludedPath, '*') && \fnmatch($excludedPath, $realpath)) { 62 | return false; 63 | } 64 | } 65 | 66 | return true; 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Traits/TraitSpotter.php: -------------------------------------------------------------------------------- 1 | traitFilesFinder->find($directories); 28 | 29 | $traitsMetadatas = []; 30 | 31 | foreach ($traitFiles as $traitFile) { 32 | $traitShortName = $traitFile->getBasename('.php'); 33 | 34 | $traitsMetadatas[] = new TraitMetadata($traitFile->getRealPath(), $traitShortName); 35 | } 36 | 37 | $traitUsageFiles = $this->traitFilesFinder->findTraitUsages($directories); 38 | 39 | foreach ($traitUsageFiles as $traitUsageFile) { 40 | $matches = Strings::matchAll($traitUsageFile->getContents(), '#^ use (?[\w]+);#m'); 41 | 42 | foreach ($matches as $match) { 43 | $shortTraitName = $match['short_trait_name']; 44 | 45 | // fuzzy in exchange for speed 46 | $currentTraitMetadata = $this->matchTraitByShortName($traitsMetadatas, $shortTraitName); 47 | if (! $currentTraitMetadata instanceof TraitMetadata) { 48 | continue; 49 | } 50 | 51 | $currentTraitMetadata->markUsedIn($traitUsageFile->getRealPath()); 52 | } 53 | } 54 | 55 | return new TraitSpottingResult($traitsMetadatas); 56 | } 57 | 58 | /** 59 | * @param TraitMetadata[] $traitsMetadatas 60 | */ 61 | private function matchTraitByShortName(array $traitsMetadatas, string $shortTraitName): ?TraitMetadata 62 | { 63 | foreach ($traitsMetadatas as $traitMetadata) { 64 | if ($traitMetadata->getShortTraitName() === $shortTraitName) { 65 | return $traitMetadata; 66 | } 67 | } 68 | 69 | return null; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/PhpParser/Finder/ClassConstantFetchFinder.php: -------------------------------------------------------------------------------- 1 | symfonyStyle->writeln('Processing ' . $phpFileInfo->getRealPath()); 40 | } 41 | 42 | $fileStmts = $this->cachedPhpParser->parseFile($phpFileInfo->getRealPath()); 43 | 44 | try { 45 | $nodeTraverser->traverse($fileStmts); 46 | } catch (ShouldNotHappenException|NotImplementedYetException $exception) { 47 | // render debug contents if verbose 48 | if ($isDebug) { 49 | $this->symfonyStyle->error($exception->getMessage()); 50 | } 51 | } 52 | 53 | if ($isDebug === false) { 54 | $progressBar->advance(); 55 | } 56 | } 57 | 58 | if ($isDebug === false) { 59 | $progressBar->finish(); 60 | } 61 | 62 | return $findClassConstFetchNodeVisitor->getClassConstantFetches(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Testing/Command/DetectUnitTestsCommand.php: -------------------------------------------------------------------------------- 1 | setName('detect-unit-tests'); 35 | 36 | $this->setDescription('Get list of tests in specific directory, that are considered "unit"'); 37 | 38 | $this->addArgument( 39 | 'sources', 40 | InputArgument::REQUIRED | InputArgument::IS_ARRAY, 41 | 'Path to directory with tests' 42 | ); 43 | } 44 | 45 | protected function execute(InputInterface $input, OutputInterface $output): int 46 | { 47 | $sources = (array) $input->getArgument('sources'); 48 | Assert::isArray($sources); 49 | Assert::allString($sources); 50 | 51 | $unitTestCasesClassesToFilePaths = $this->unitTestFilePathsFinder->findInDirectories($sources); 52 | 53 | if ($unitTestCasesClassesToFilePaths === []) { 54 | $this->symfonyStyle->note('No unit tests found in provided paths'); 55 | return self::SUCCESS; 56 | } 57 | 58 | $filesPHPUnitXmlContents = $this->phpunitXmlPrinter->printFiles($unitTestCasesClassesToFilePaths); 59 | 60 | FileSystem::write(self::OUTPUT_FILENAME, $filesPHPUnitXmlContents, null); 61 | 62 | $successMessage = sprintf( 63 | 'List of %d unit tests was dumped into "%s"', 64 | count($unitTestCasesClassesToFilePaths), 65 | self::OUTPUT_FILENAME, 66 | ); 67 | 68 | $this->symfonyStyle->success($successMessage); 69 | 70 | return self::SUCCESS; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Command/FindMultiClassesCommand.php: -------------------------------------------------------------------------------- 1 | setName('find-multi-classes'); 29 | 30 | $this->setDescription('Find multiple classes in one file'); 31 | 32 | $this->addArgument( 33 | 'sources', 34 | InputArgument::REQUIRED | InputArgument::IS_ARRAY, 35 | 'Path to source to analyse' 36 | ); 37 | 38 | $this->addOption( 39 | 'exclude-path', 40 | null, 41 | InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 42 | 'Path to exclude' 43 | ); 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output): int 47 | { 48 | /** @var string[] $source */ 49 | $source = $input->getArgument('sources'); 50 | 51 | $excludedPaths = (array) $input->getOption('exclude-path'); 52 | 53 | $phpFileInfos = PhpFilesFinder::find($source, $excludedPaths); 54 | 55 | $multipleClassesByFile = $this->multipleClassInOneFileFinder->findInDirectories($source, $excludedPaths); 56 | if ($multipleClassesByFile === []) { 57 | $this->symfonyStyle->success(sprintf('No file with 2+ classes found in %d files', count($phpFileInfos))); 58 | 59 | return self::SUCCESS; 60 | } 61 | 62 | foreach ($multipleClassesByFile as $filePath => $classes) { 63 | // get relative path to getcwd() 64 | $relativeFilePath = PathHelper::relativeToCwd($filePath); 65 | 66 | $message = sprintf('File "%s" contains %d classes', $relativeFilePath, count($classes)); 67 | $this->symfonyStyle->section($message); 68 | $this->symfonyStyle->listing($classes); 69 | } 70 | 71 | return self::FAILURE; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.github/workflows/downgraded_release.yaml: -------------------------------------------------------------------------------- 1 | name: Downgraded Release 2 | 3 | on: 4 | push: 5 | tags: 6 | # avoid infinite looping, skip tags that ends with ".74" 7 | # see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#example-including-and-excluding-branches 8 | - '*' 9 | 10 | jobs: 11 | downgrade_release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: "actions/checkout@v3" 16 | with: 17 | token: ${{ secrets.WORKFLOWS_TOKEN }} 18 | 19 | - 20 | uses: "shivammathur/setup-php@v2" 21 | with: 22 | php-version: 8.2 23 | coverage: none 24 | 25 | # invoke patches 26 | - run: composer install --ansi 27 | 28 | # but no dev packages 29 | - run: composer update --no-dev --ansi 30 | 31 | # get rector to "rector-local" directory, to avoid downgrading itself in the /vendor 32 | - run: mkdir rector-local 33 | - run: composer require rector/rector --working-dir rector-local --ansi 34 | 35 | # downgrade to PHP 7.4 36 | - run: rector-local/vendor/bin/rector process bin src vendor --config build/rector-downgrade-php.php --ansi 37 | 38 | # clear the dev files 39 | - run: rm -rf tests ecs.php phpstan.neon phpunit.xml .gitignore .editorconfig 40 | 41 | # prefix and scope 42 | - run: sh prefix-code.sh 43 | 44 | # copy PHP 7.4 composer + workflows 45 | - run: cp -r build/target-repository/. . 46 | 47 | # clear the dev files 48 | - run: rm -rf build prefix-code.sh full-tool-build.sh scoper.php rector.php php-scoper.phar rector-local 49 | 50 | # setup git user 51 | - 52 | run: | 53 | git config user.email "action@github.com" 54 | git config user.name "GitHub Action" 55 | # publish to the same repository with a new tag 56 | # see https://tomasvotruba.com/blog/how-to-release-php-81-and-72-package-in-the-same-repository/ 57 | - 58 | name: "Tag Downgraded Code" 59 | run: | 60 | # separate a "git add" to add untracked (new) files too 61 | git add --all 62 | git commit -m "release PHP 7.2 downgraded" 63 | 64 | # force push tag, so there is only 1 version 65 | git tag "${GITHUB_REF#refs/tags/}" --force 66 | git push origin "${GITHUB_REF#refs/tags/}" --force 67 | -------------------------------------------------------------------------------- /src/PhpParser/NodeVisitor/FindNonPrivateClassConstNodeVisitor.php: -------------------------------------------------------------------------------- 1 | isAnonymous() || $node->isAbstract()) { 29 | return null; 30 | } 31 | 32 | Assert::isInstanceOf($node->namespacedName, Name::class); 33 | 34 | $className = $node->namespacedName->toString(); 35 | foreach ($node->getConstants() as $constant) { 36 | foreach ($constant->consts as $constConst) { 37 | $constantName = $constConst->name->toString(); 38 | 39 | // not interested in private constants 40 | if ($constant->isPrivate()) { 41 | continue; 42 | } 43 | 44 | if ($this->isConstantDefinedInParentClassAlso($node, $constantName)) { 45 | continue; 46 | } 47 | 48 | $this->classConstants[] = new ClassConstant($className, $constantName); 49 | } 50 | } 51 | 52 | return $node; 53 | } 54 | 55 | /** 56 | * @return ClassConstant[] 57 | */ 58 | public function getClassConstants(): array 59 | { 60 | return $this->classConstants; 61 | } 62 | 63 | private function isConstantDefinedInParentClassAlso(Class_ $class, string $constantName): bool 64 | { 65 | if ($class->extends instanceof Node) { 66 | $parentClassName = $class->extends->toString(); 67 | if (class_exists($parentClassName)) { 68 | return in_array($constantName, $this->getClassConstantNames($parentClassName), true); 69 | } 70 | } 71 | 72 | foreach ($class->implements as $implement) { 73 | if (in_array($constantName, $this->getClassConstantNames($implement->toString()), true)) { 74 | return true; 75 | } 76 | } 77 | 78 | return false; 79 | } 80 | 81 | /** 82 | * @return string[] 83 | */ 84 | private function getClassConstantNames(string $className): array 85 | { 86 | $reflectionClass = new ReflectionClass($className); 87 | 88 | return array_keys($reflectionClass->getConstants()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/DependencyInjection/ContainerFactory.php: -------------------------------------------------------------------------------- 1 | singleton(Application::class, function (Container $container): Application { 28 | $application = new Application('Rector Swiss Knife'); 29 | 30 | $commandClasses = $this->findCommandClasses(); 31 | 32 | // register commands 33 | foreach ($commandClasses as $commandClass) { 34 | $command = $container->make($commandClass); 35 | $application->add($command); 36 | } 37 | 38 | // remove basic command to make output clear 39 | $this->hideDefaultCommands($application); 40 | 41 | return $application; 42 | }); 43 | 44 | // parser 45 | $container->singleton(Parser::class, static function (): Parser { 46 | $phpParserFactory = new ParserFactory(); 47 | return $phpParserFactory->createForNewestSupportedVersion(); 48 | }); 49 | 50 | $container->singleton( 51 | SymfonyStyle::class, 52 | static fn (): SymfonyStyle => new SymfonyStyle(new ArrayInput([]), new ConsoleOutput()) 53 | ); 54 | 55 | return $container; 56 | } 57 | 58 | public function hideDefaultCommands(Application $application): void 59 | { 60 | $application->get('list') 61 | ->setHidden(true); 62 | $application->get('completion') 63 | ->setHidden(true); 64 | $application->get('help') 65 | ->setHidden(true); 66 | } 67 | 68 | /** 69 | * @return string[] 70 | */ 71 | private function findCommandClasses(): array 72 | { 73 | $commandFinder = Finder::create() 74 | ->files() 75 | ->name('*Command.php') 76 | ->in(__DIR__ . '/../Command'); 77 | 78 | $commandClasses = []; 79 | foreach ($commandFinder as $commandFile) { 80 | $commandClass = 'Rector\\SwissKnife\\Command\\' . $commandFile->getBasename('.php'); 81 | 82 | // make sure it exists 83 | Assert::classExists($commandClass); 84 | 85 | $commandClasses[] = $commandClass; 86 | } 87 | 88 | return $commandClasses; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Finder/FilesFinder.php: -------------------------------------------------------------------------------- 1 | files() 26 | ->in($paths) 27 | // not our code 28 | ->notPath('node_modules') 29 | ->notPath('vendor') 30 | ->notPath('var/cache') 31 | ->sortByName(); 32 | 33 | return iterator_to_array($finder->getIterator()); 34 | } 35 | 36 | /** 37 | * @param string[] $directories 38 | * @return SplFileInfo[] 39 | */ 40 | public static function findTwigFiles(array $directories): array 41 | { 42 | Assert::allString($directories); 43 | Assert::allDirectory($directories); 44 | 45 | $twigFinder = Finder::create() 46 | ->files() 47 | ->name('*.twig') 48 | ->in($directories) 49 | ->sortByName(); 50 | 51 | return iterator_to_array($twigFinder->getIterator()); 52 | } 53 | 54 | /** 55 | * @param string[] $sources 56 | * @return SplFileInfo[] 57 | */ 58 | public static function findJsonFiles(array $sources): array 59 | { 60 | $jsonFileInfos = []; 61 | $directories = []; 62 | 63 | foreach ($sources as $source) { 64 | if (is_file($source)) { 65 | $jsonFileInfos[] = new SplFileInfo($source, '', $source); 66 | } else { 67 | $directories[] = $source; 68 | } 69 | } 70 | 71 | $jsonFileFinder = Finder::create() 72 | ->files() 73 | ->in($directories) 74 | ->name('*.json') 75 | ->sortByName(); 76 | 77 | foreach ($jsonFileFinder->getIterator() as $fileInfo) { 78 | $jsonFileInfos[] = $fileInfo; 79 | } 80 | 81 | return $jsonFileInfos; 82 | } 83 | 84 | /** 85 | * @param string[] $paths 86 | * @return SplFileInfo[] 87 | */ 88 | public static function findYamlFiles(array $paths): array 89 | { 90 | Assert::allString($paths); 91 | Assert::allFileExists($paths); 92 | 93 | $finder = Finder::create() 94 | ->files() 95 | ->in($paths) 96 | ->name('*.yml') 97 | ->name('*.yaml'); 98 | 99 | return iterator_to_array($finder); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Command/SearchRegexCommand.php: -------------------------------------------------------------------------------- 1 | setName('search-regex'); 28 | 29 | $this->addArgument( 30 | 'regex', 31 | InputArgument::REQUIRED, 32 | 'Code snippet to look in PHP files in the whole codebase' 33 | ); 34 | 35 | $this->addOption('project-directory', null, InputOption::VALUE_REQUIRED, 'Project directory', getcwd()); 36 | 37 | $this->setDescription('Search for regex in PHP files of the whole codebase'); 38 | } 39 | 40 | protected function execute(InputInterface $input, OutputInterface $output): int 41 | { 42 | $regex = (string) $input->getArgument('regex'); 43 | 44 | $projectDirectory = (string) $input->getOption('project-directory'); 45 | 46 | Assert::directory($projectDirectory); 47 | 48 | $phpFileInfos = PhpFilesFinder::find([$projectDirectory]); 49 | 50 | $message = sprintf('Going through %d *.php files', count($phpFileInfos)); 51 | $this->symfonyStyle->writeln($message); 52 | 53 | $this->symfonyStyle->writeln('Searching for regex: ' . $regex); 54 | $this->symfonyStyle->newLine(); 55 | 56 | $foundCasesCount = 0; 57 | $markedFiles = []; 58 | 59 | $progressBar = $this->symfonyStyle->createProgressBar(count($phpFileInfos)); 60 | 61 | foreach ($phpFileInfos as $phpFileInfo) { 62 | $matches = Strings::matchAll($phpFileInfo->getContents(), $regex); 63 | $currentMatchesCount = count($matches); 64 | if ($currentMatchesCount === 0) { 65 | continue; 66 | } 67 | 68 | $foundCasesCount += $currentMatchesCount; 69 | $markedFiles[$phpFileInfo->getRelativePathname()] = $currentMatchesCount; 70 | 71 | $progressBar->advance(); 72 | } 73 | 74 | $progressBar->finish(); 75 | $this->symfonyStyle->newLine(2); 76 | 77 | ksort($markedFiles); 78 | foreach ($markedFiles as $filePath => $count) { 79 | $this->symfonyStyle->writeln(sprintf(' * %s: %d', $filePath, $count)); 80 | } 81 | 82 | $this->symfonyStyle->newLine(2); 83 | $this->symfonyStyle->success(sprintf('Found %d cases in %d files', $foundCasesCount, count($markedFiles))); 84 | 85 | return self::SUCCESS; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /scoper.php: -------------------------------------------------------------------------------- 1 | format('Ym'); 9 | 10 | // @see https://github.com/humbug/php-scoper/blob/master/docs/further-reading.md 11 | use Nette\Utils\Strings; 12 | 13 | // see https://github.com/humbug/php-scoper 14 | return [ 15 | 'prefix' => 'SwissKnife' . $timestamp, 16 | // exclude 17 | 'exclude-classes' => [ 18 | // native class on php 8.3+ 19 | 'DateRangeError', 20 | ], 21 | 'expose-constants' => ['#^SYMFONY\_[\p{L}_]+$#'], 22 | 'exclude-namespaces' => [ 23 | '#^Rector\\\\SwissKnife#', 24 | '#^Symfony\\\\Polyfill#', 25 | '#^PHPUnit\\\\', 26 | '#^Symfony\\\\Component\\\\Config#', 27 | '#^Symfony\\\\Component\\\\DependencyInjection#', 28 | ], 29 | 'exclude-files' => [ 30 | // do not prefix "trigger_deprecation" from symfony - https://github.com/symfony/symfony/commit/0032b2a2893d3be592d4312b7b098fb9d71aca03 31 | // these paths are relative to this file location, so it should be in the root directory 32 | 'vendor/symfony/deprecation-contracts/function.php', 33 | 'stubs/PHPUnit/PHPUnit_Framework_TestCase.php', 34 | 35 | // keep class references 36 | 'src/Enum/SymfonyExtensionClass.php', 37 | 38 | // uses native PHPUnit TestCase class in the project 39 | 'src/Testing/PHPUnitMocker.php', 40 | 'src/Testing/MockWire.php', 41 | ], 42 | 'patchers' => [ 43 | // unprefix test case class names 44 | function (string $filePath, string $prefix, string $content): string { 45 | if (! str_ends_with($filePath, 'packages/Testing/UnitTestFilter.php') 46 | && 47 | ! str_ends_with($filePath, 'src/Testing/MockWire.php') 48 | ) { 49 | return $content; 50 | } 51 | 52 | $content = Strings::replace( 53 | $content, 54 | '#' . $prefix . '\\\\PHPUnit\\\\Framework\\\\TestCase#', 55 | 'PHPUnit\Framework\TestCase' 56 | ); 57 | 58 | return Strings::replace( 59 | $content, 60 | '#' . $prefix . '\\\\PHPUnit_Framework_TestCase#', 61 | 'PHPUnit_Framework_TestCase' 62 | ); 63 | }, 64 | 65 | // unprefix kernerl test case class names 66 | function (string $filePath, string $prefix, string $content): string { 67 | if (! str_ends_with($filePath, 'packages/Testing/UnitTestFilter.php')) { 68 | return $content; 69 | } 70 | 71 | $content = Strings::replace( 72 | $content, 73 | '#' . $prefix . '\\\\Symfony\\\\Bundle\\\\FrameworkBundle\\\\Test\\\\KernelTestCase#', 74 | 'Symfony\Bundle\FrameworkBundle\Test\KernelTestCase' 75 | ); 76 | 77 | return Strings::replace( 78 | $content, 79 | '#' . $prefix . '\\\\Symfony\\\\Component\\\\Form\\\\Test\\\\TypeTestCase', 80 | 'Symfony\Component\Form\Test\TypeTestCase' 81 | ); 82 | }, 83 | ], 84 | ]; 85 | -------------------------------------------------------------------------------- /src/Command/CheckCommentedCodeCommand.php: -------------------------------------------------------------------------------- 1 | setName('check-commented-code'); 33 | 34 | $this->addArgument( 35 | 'sources', 36 | InputArgument::REQUIRED | InputArgument::IS_ARRAY, 37 | 'One or more paths to check' 38 | ); 39 | $this->addOption('skip-file', null, InputOption::VALUE_REQUIRED, 'Skip file path'); 40 | $this->setDescription('Checks code for commented snippets'); 41 | 42 | $this->addOption( 43 | 'line-limit', 44 | null, 45 | InputOption::VALUE_REQUIRED | InputOption::VALUE_OPTIONAL, 46 | 'Amount of allowed comment lines in a row', 47 | self::DEFAULT_LINE_LIMIT 48 | ); 49 | } 50 | 51 | protected function execute(InputInterface $input, OutputInterface $output): int 52 | { 53 | $sources = (array) $input->getArgument('sources'); 54 | $skipFiles = (array) $input->getOption('skip-file'); 55 | 56 | $phpFileInfos = PhpFilesFinder::find($sources, $skipFiles); 57 | 58 | $message = sprintf('Analysing %d *.php files', count($phpFileInfos)); 59 | $this->symfonyStyle->note($message); 60 | 61 | $lineLimit = (int) $input->getOption('line-limit'); 62 | 63 | $commentedLinesByFilePaths = []; 64 | foreach ($phpFileInfos as $phpFileInfo) { 65 | $commentedLines = $this->commentedCodeAnalyzer->process($phpFileInfo->getRealPath(), $lineLimit); 66 | 67 | if ($commentedLines === []) { 68 | continue; 69 | } 70 | 71 | $commentedLinesByFilePaths[$phpFileInfo->getRealPath()] = $commentedLines; 72 | } 73 | 74 | if ($commentedLinesByFilePaths === []) { 75 | $this->symfonyStyle->success('No commented code found'); 76 | return self::SUCCESS; 77 | } 78 | 79 | foreach ($commentedLinesByFilePaths as $filePath => $commentedLines) { 80 | foreach ($commentedLines as $commentedLine) { 81 | $messageLine = ' * ' . $filePath . ':' . $commentedLine; 82 | $this->symfonyStyle->writeln($messageLine); 83 | } 84 | } 85 | 86 | $this->symfonyStyle->error('Errors found'); 87 | return self::FAILURE; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Command/AliceYamlFixturesToPhpCommand.php: -------------------------------------------------------------------------------- 1 | setName('alice-yaml-fixtures-to-php'); 33 | 34 | $this->addArgument( 35 | 'sources', 36 | InputArgument::REQUIRED | InputArgument::IS_ARRAY, 37 | 'One or more paths to check' 38 | ); 39 | 40 | $this->setDescription('Converts Alice YAML fixtures to PHP format, so Rector and PHPStan can understand it'); 41 | } 42 | 43 | protected function execute(InputInterface $input, OutputInterface $output): int 44 | { 45 | $sources = (array) $input->getArgument('sources'); 46 | $yamlFileInfos = FilesFinder::findYamlFiles($sources); 47 | 48 | $standard = new Standard(); 49 | 50 | // use php-parser to dump their PHP version, @see https://github.com/nelmio/alice/blob/main/doc%2Fcomplete-reference.md#php 51 | foreach ($yamlFileInfos as $yamlFileInfo) { 52 | $yaml = Yaml::parseFile($yamlFileInfo->getRealPath()); 53 | 54 | $return = $this->createArrayReturn($yaml); 55 | $phpFileContents = $standard->prettyPrintFile([$return]); 56 | 57 | // get real path without yml/yaml suffix 58 | if (str_ends_with($yamlFileInfo->getRealPath(), '.yml')) { 59 | $phpFilePath = substr($yamlFileInfo->getRealPath(), 0, -4) . '.php'; 60 | } else { 61 | $phpFilePath = substr($yamlFileInfo->getRealPath(), 0, -5) . '.php'; 62 | } 63 | 64 | FileSystem::write($phpFilePath, $phpFileContents, null); 65 | 66 | // remove YAML file 67 | unlink($yamlFileInfo->getRealPath()); 68 | 69 | $this->symfonyStyle->writeln('[DELETED] ' . $yamlFileInfo->getRelativePathname()); 70 | $this->symfonyStyle->writeln('[ADDED] ' . $phpFilePath); 71 | $this->symfonyStyle->newLine(); 72 | } 73 | 74 | $this->symfonyStyle->success( 75 | sprintf('Successfully converted %d Alice YAML fixtures to PHP', count($yamlFileInfos)) 76 | ); 77 | 78 | return self::SUCCESS; 79 | } 80 | 81 | /** 82 | * @param mixed[] $yaml 83 | */ 84 | private function createArrayReturn(array $yaml): Return_ 85 | { 86 | $expr = BuilderHelpers::normalizeValue($yaml); 87 | 88 | return new Return_($expr); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/PhpParser/NodeVisitor/MockedClassNameCollectingNodeVisitor.php: -------------------------------------------------------------------------------- 1 | name instanceof Identifier) { 31 | return null; 32 | } 33 | 34 | $methodName = $node->name->toString(); 35 | $mockMethodNames = [ 36 | 'createMock', // https://github.com/sebastianbergmann/phpunit/blob/d72b735d34bbff2065cef80653cafbe31cb45ba0/src/Framework/TestCase.php#L1177 37 | 'createPartialMock', // https://github.com/sebastianbergmann/phpunit/blob/d72b735d34bbff2065cef80653cafbe31cb45ba0/src/Framework/TestCase.php#L1257 38 | 'getMock', // https://github.com/sebastianbergmann/phpunit/blob/d72b735d34bbff2065cef80653cafbe31cb45ba0/src/Framework/MockObject/MockBuilder.php#L86 39 | 'getMockBuilder', // https://github.com/sebastianbergmann/phpunit/blob/d72b735d34bbff2065cef80653cafbe31cb45ba0/src/Framework/TestCase.php#L1095 40 | 'mock', // https://github.com/mockery/mockery/blob/73a9714716f87510a7c2add9931884188e657541/library/Mockery.php#L475, https://github.com/laravel/framework/blob/4ca4a16772b2e89233b3606badefae34003e1538/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php#L69 41 | 'partialMock', // https://github.com/laravel/framework/blob/4ca4a16772b2e89233b3606badefae34003e1538/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php#L81 42 | 'spy', // https://github.com/mockery/mockery/blob/73a9714716f87510a7c2add9931884188e657541/library/Mockery.php#L675, https://github.com/laravel/framework/blob/4ca4a16772b2e89233b3606badefae34003e1538/src/Illuminate/Foundation/Testing/Concerns/InteractsWithContainer.php#L93 43 | ]; 44 | if (! in_array($methodName, $mockMethodNames, true)) { 45 | return null; 46 | } 47 | 48 | $mockedClassArg = $node->getArgs()[0] ?? null; 49 | if (! $mockedClassArg instanceof Arg) { 50 | return null; 51 | } 52 | 53 | // get class name 54 | if ($mockedClassArg->value instanceof ClassConstFetch) { 55 | $mockedClass = $mockedClassArg->value->class; 56 | if ($mockedClass instanceof Name) { 57 | $this->mockedClassNames[] = $mockedClass->toString(); 58 | } 59 | } 60 | 61 | return $node; 62 | } 63 | 64 | /** 65 | * @return string[] 66 | */ 67 | public function getMockedClassNames(): array 68 | { 69 | $uniqueMockedClassNames = array_unique($this->mockedClassNames); 70 | sort($uniqueMockedClassNames); 71 | 72 | return $uniqueMockedClassNames; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Command/PrettyJsonCommand.php: -------------------------------------------------------------------------------- 1 | setName('pretty-json'); 30 | 31 | $this->setDescription('Turns JSON files from 1-line to pretty print format'); 32 | 33 | $this->addArgument( 34 | 'sources', 35 | InputArgument::REQUIRED | InputArgument::IS_ARRAY, 36 | 'JSON file or directory with JSON files to prettify' 37 | ); 38 | 39 | $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run - no changes will be made'); 40 | } 41 | 42 | protected function execute(InputInterface $input, OutputInterface $output): int 43 | { 44 | $sources = (array) $input->getArgument('sources'); 45 | $jsonFileInfos = FilesFinder::findJsonFiles($sources); 46 | 47 | if ($jsonFileInfos === []) { 48 | $this->symfonyStyle->error('No *.json files found'); 49 | return self::FAILURE; 50 | } 51 | 52 | $message = sprintf('Analysing %d *.json files', count($jsonFileInfos)); 53 | $this->symfonyStyle->note($message); 54 | 55 | $isDryRun = (bool) $input->getOption('dry-run'); 56 | 57 | $printedFilePaths = []; 58 | 59 | // convert file infos from uggly json to pretty json 60 | foreach ($jsonFileInfos as $jsonFileInfo) { 61 | $jsonContent = FileSystem::read($jsonFileInfo->getRealPath()); 62 | if ($this->jsonAnalyzer->isPrettyPrinted($jsonContent)) { 63 | $this->symfonyStyle->writeln( 64 | sprintf('File "%s" is already pretty', $jsonFileInfo->getRelativePathname()) 65 | ); 66 | continue; 67 | } 68 | 69 | // notify the file was changed 70 | $printedFilePaths[] = $jsonFileInfo->getRelativePathname(); 71 | 72 | // nothing will be changed 73 | if ($isDryRun) { 74 | continue; 75 | } 76 | 77 | $prettyJsonContent = Json::encode(Json::decode($jsonContent), JSON_PRETTY_PRINT); 78 | FileSystem::write($jsonFileInfo->getRealPath(), $prettyJsonContent, null); 79 | } 80 | 81 | $successMessage = sprintf( 82 | '%d file%s %s', 83 | count($printedFilePaths), 84 | count($printedFilePaths) === 1 ? '' : 's', 85 | $isDryRun ? 'would be changed' : 'changed' 86 | ); 87 | 88 | $this->symfonyStyle->success($successMessage); 89 | $this->symfonyStyle->listing($printedFilePaths); 90 | 91 | return self::SUCCESS; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Command/SpotLazyTraitsCommand.php: -------------------------------------------------------------------------------- 1 | setName('spot-lazy-traits'); 27 | 28 | $this->addArgument( 29 | 'sources', 30 | InputArgument::REQUIRED | InputArgument::IS_ARRAY, 31 | 'One or more paths to check' 32 | ); 33 | 34 | $this->addOption('max-used', null, InputOption::VALUE_REQUIRED, 'Maximum count the trait is used', 2); 35 | 36 | $this->setDescription( 37 | 'Spot traits that are use only once, to potentially inline them and make code more robust and readable' 38 | ); 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int 42 | { 43 | $sources = (array) $input->getArgument('sources'); 44 | $maxUsedCount = (int) $input->getOption('max-used'); 45 | 46 | $this->symfonyStyle->title('Looking for trait definitions'); 47 | $traitSpottingResult = $this->traitSpotter->analyse($sources); 48 | 49 | if ($traitSpottingResult->getTraitCount() === 0) { 50 | $this->symfonyStyle->success('No traits were found in your project, nothing to worry about'); 51 | return self::SUCCESS; 52 | } 53 | 54 | $this->symfonyStyle->writeln( 55 | sprintf( 56 | 'Found %d trait%s in the whole project', 57 | $traitSpottingResult->getTraitCount(), 58 | $traitSpottingResult->getTraitCount() === 1 ? '' : 's' 59 | ) 60 | ); 61 | $this->symfonyStyle->listing($traitSpottingResult->getTraitFilePaths()); 62 | 63 | $this->symfonyStyle->newLine(); 64 | 65 | $this->symfonyStyle->title(sprintf('Looking for traits used less than %d-times', $maxUsedCount)); 66 | 67 | $leastUsedTraitsMetadatas = $traitSpottingResult->getTraitMaximumUsedTimes($maxUsedCount); 68 | 69 | foreach ($leastUsedTraitsMetadatas as $leastUsedTraitMetadata) { 70 | $this->symfonyStyle->writeln(sprintf( 71 | 'Trait "%s" (%d lines) is used only in %d file%s', 72 | $leastUsedTraitMetadata->getShortTraitName(), 73 | $leastUsedTraitMetadata->getLineCount(), 74 | $leastUsedTraitMetadata->getUsedInCount(), 75 | $leastUsedTraitMetadata->getUsedInCount() === 1 ? '' : 's' 76 | )); 77 | 78 | $this->symfonyStyle->listing($leastUsedTraitMetadata->getUsedIn()); 79 | $this->symfonyStyle->newLine(); 80 | } 81 | 82 | $this->symfonyStyle->warning(sprintf( 83 | 'Inline these traits or refactor them to a service if meaningful.%sChange "--max-used" to different number to get more result', 84 | PHP_EOL 85 | )); 86 | 87 | return self::SUCCESS; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/EntityClassResolver.php: -------------------------------------------------------------------------------- 1 | [\w+\\\\]+)\:\n#m'; 27 | 28 | public function __construct( 29 | private CachedPhpParser $cachedPhpParser 30 | ) { 31 | } 32 | 33 | /** 34 | * @param string[] $paths 35 | * @return string[] 36 | */ 37 | public function resolve(array $paths, ?callable $progressClosure = null): array 38 | { 39 | Assert::allString($paths); 40 | Assert::allFileExists($paths); 41 | 42 | // 1. resolve from yaml annotations 43 | $yamlEntityClassNames = $this->resolveYamlEntityClassNames($paths); 44 | 45 | // 2. resolve from direct class names with namespace parts, doctrine annotation or docblock 46 | $phpFileInfos = PhpFilesFinder::find($paths); 47 | $entityClassNameCollectingNodeVisitor = new EntityClassNameCollectingNodeVisitor(); 48 | 49 | $nodeTraverser = NodeTraverserFactory::create($entityClassNameCollectingNodeVisitor); 50 | $this->traverseFileInfos($phpFileInfos, $nodeTraverser, $progressClosure); 51 | 52 | $markedEntityClassNames = $entityClassNameCollectingNodeVisitor->getEntityClassNames(); 53 | 54 | $entityClassNames = array_merge($yamlEntityClassNames, $markedEntityClassNames); 55 | sort($entityClassNames); 56 | 57 | return array_unique($entityClassNames); 58 | } 59 | 60 | /** 61 | * @param SplFileInfo[] $phpFileInfos 62 | */ 63 | private function traverseFileInfos( 64 | array $phpFileInfos, 65 | NodeTraverser $nodeTraverser, 66 | ?callable $progressClosure = null 67 | ): void { 68 | foreach ($phpFileInfos as $phpFileInfo) { 69 | $stmts = $this->cachedPhpParser->parseFile($phpFileInfo->getRealPath()); 70 | 71 | $nodeTraverser->traverse($stmts); 72 | 73 | if (is_callable($progressClosure)) { 74 | $progressClosure(); 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * @param string[] $paths 81 | * @return string[] 82 | */ 83 | private function resolveYamlEntityClassNames(array $paths): array 84 | { 85 | $yamlFileInfos = FilesFinder::findYamlFiles($paths); 86 | 87 | $yamlEntityClassNames = []; 88 | 89 | /** @var SplFileInfo $yamlFileInfo */ 90 | foreach ($yamlFileInfos as $yamlFileInfo) { 91 | $matches = Strings::matchAll($yamlFileInfo->getContents(), self::YAML_ENTITY_CLASS_NAME_REGEX); 92 | 93 | foreach ($matches as $match) { 94 | $yamlEntityClassNames[] = $match['class_name']; 95 | } 96 | } 97 | 98 | return $yamlEntityClassNames; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/PhpParser/NodeVisitor/EntityClassNameCollectingNodeVisitor.php: -------------------------------------------------------------------------------- 1 | namespacedName instanceof Name) { 39 | return null; 40 | } 41 | 42 | if ($this->hasEntityAnnotation($node) || $this->hasEntityAttribute($node)) { 43 | $this->entityClassNames[] = $node->namespacedName->toString(); 44 | return null; 45 | } 46 | 47 | return $node; 48 | } 49 | 50 | /** 51 | * @return string[] 52 | */ 53 | public function getEntityClassNames(): array 54 | { 55 | $uniqueEntityClassNames = array_unique($this->entityClassNames); 56 | sort($uniqueEntityClassNames); 57 | 58 | return $uniqueEntityClassNames; 59 | } 60 | 61 | /** 62 | * @param string[] $suffixes 63 | */ 64 | private function hasDocBlockSuffixes(Class_ $class, array $suffixes): bool 65 | { 66 | Assert::allString($suffixes); 67 | 68 | $docComment = $class->getDocComment(); 69 | if ($docComment instanceof Doc) { 70 | // dummy check 71 | if (! str_contains($docComment->getText(), '@')) { 72 | return false; 73 | } 74 | 75 | foreach ($suffixes as $suffix) { 76 | if (str_contains($docComment->getText(), $suffix)) { 77 | return true; 78 | } 79 | } 80 | } 81 | 82 | return false; 83 | } 84 | 85 | /** 86 | * @param string[] $suffixes 87 | */ 88 | private function hasAttributeSuffixes(Class_ $class, array $suffixes): bool 89 | { 90 | Assert::allString($suffixes); 91 | 92 | foreach ($class->attrGroups as $attrGroup) { 93 | foreach ($attrGroup->attrs as $attr) { 94 | foreach ($suffixes as $suffix) { 95 | if (str_ends_with($attr->name->toString(), $suffix)) { 96 | return true; 97 | } 98 | } 99 | } 100 | } 101 | 102 | return false; 103 | } 104 | 105 | private function hasEntityAnnotation(Class_ $class): bool 106 | { 107 | if ($this->hasDocBlockSuffixes($class, self::ODM_SUFFIXES)) { 108 | return true; 109 | } 110 | 111 | return $this->hasDocBlockSuffixes($class, self::ORM_SUFFIXES); 112 | } 113 | 114 | private function hasEntityAttribute(Class_ $class): bool 115 | { 116 | if ($this->hasAttributeSuffixes($class, self::ODM_SUFFIXES)) { 117 | return true; 118 | } 119 | 120 | return $this->hasAttributeSuffixes($class, self::ORM_SUFFIXES); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Command/GenerateSymfonyConfigBuildersCommand.php: -------------------------------------------------------------------------------- 1 | setName('generate-symfony-config-builders'); 46 | 47 | $this->setDescription( 48 | 'Generate Symfony config classes to /var/cache/Symfony directory, see https://symfony.com/blog/new-in-symfony-5-3-config-builder-classes' 49 | ); 50 | } 51 | 52 | protected function execute(InputInterface $input, OutputInterface $output): int 53 | { 54 | // make sure the classes exist 55 | if (! class_exists(ConfigBuilderGenerator::class) || ! class_exists(ContainerBuilder::class)) { 56 | $this->symfonyStyle->error( 57 | 'This command requires symfony/config and symfony/dependency-injection 5.3+ to run. Update your dependencies or install them first.' 58 | ); 59 | 60 | return self::FAILURE; 61 | } 62 | 63 | $configBuilderGenerator = new ConfigBuilderGenerator(getcwd() . '/var/cache'); 64 | $this->symfonyStyle->newLine(); 65 | 66 | foreach (self::EXTENSION_CLASSES as $extensionClass) { 67 | // skip for non-existing classes 68 | if (! class_exists($extensionClass)) { 69 | continue; 70 | } 71 | 72 | $configuration = $this->createExtensionConfiguration($extensionClass); 73 | if (! $configuration instanceof ConfigurationInterface) { 74 | continue; 75 | } 76 | 77 | $extensionShortClass = (new ReflectionClass($extensionClass))->getShortName(); 78 | $configBuilderGenerator->build($configuration); 79 | 80 | $this->symfonyStyle->writeln(sprintf('Generated "%s" class in /var/cache/Symfony', $extensionShortClass)); 81 | } 82 | 83 | $this->symfonyStyle->success('Done'); 84 | 85 | return self::SUCCESS; 86 | } 87 | 88 | /** 89 | * @param class-string $extensionClass 90 | */ 91 | private function createExtensionConfiguration(string $extensionClass): ?ConfigurationInterface 92 | { 93 | $containerBuilder = new ContainerBuilder(); 94 | $containerBuilder->setParameter('kernel.debug', false); 95 | 96 | /** @var Extension $extension */ 97 | $extension = new $extensionClass(); 98 | 99 | return $extension->getConfiguration([], $containerBuilder); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Command/NamespaceToPSR4Command.php: -------------------------------------------------------------------------------- 1 | setName('namespace-to-psr-4'); 29 | 30 | $this->setDescription('Change namespace in your PHP files to match PSR-4 root'); 31 | 32 | $this->addArgument( 33 | 'path', 34 | InputArgument::REQUIRED, 35 | 'Single directory path to ensure namespace matches, e.g. "tests"' 36 | ); 37 | 38 | $this->addOption( 39 | 'namespace-root', 40 | null, 41 | InputOption::VALUE_REQUIRED, 42 | 'Namespace root for files in provided path, e.g. "App\\Tests"' 43 | ); 44 | } 45 | 46 | /** 47 | * @return self::* 48 | */ 49 | protected function execute(InputInterface $input, OutputInterface $output): int 50 | { 51 | $path = (string) $input->getArgument('path'); 52 | $namespaceRoot = rtrim((string) $input->getOption('namespace-root'), '\\'); 53 | $namespaceRoot = str_replace('\\\\', '\\', $namespaceRoot); 54 | 55 | $fileInfos = $this->findFilesInPath($path); 56 | 57 | $changedFilesCount = 0; 58 | 59 | /** @var SplFileInfo $fileInfo */ 60 | foreach ($fileInfos as $fileInfo) { 61 | $expectedNamespace = $this->resolveExpectedNamespace($namespaceRoot, $fileInfo); 62 | $expectedNamespaceLine = 'namespace ' . $expectedNamespace . ';'; 63 | 64 | // 1. got the correct namespace 65 | if (\str_contains($fileInfo->getContents(), $expectedNamespaceLine)) { 66 | continue; 67 | } 68 | 69 | // 2. incorrect namespace found 70 | $this->symfonyStyle->note(sprintf( 71 | 'File "%s"%s fixed to expected namespace "%s"', 72 | $fileInfo->getRelativePathname(), 73 | PHP_EOL, 74 | $expectedNamespace 75 | )); 76 | 77 | // 3. replace 78 | $correctedContents = Strings::replace( 79 | $fileInfo->getContents(), 80 | '#namespace (.*?);#', 81 | $expectedNamespaceLine 82 | ); 83 | 84 | // 4. print file 85 | FileSystem::write($fileInfo->getRealPath(), $correctedContents, null); 86 | 87 | ++$changedFilesCount; 88 | } 89 | 90 | if ($changedFilesCount === 0) { 91 | $this->symfonyStyle->success(sprintf('All %d files have correct namespace', count($fileInfos))); 92 | } else { 93 | $this->symfonyStyle->success(sprintf('Fixed %d files', $changedFilesCount)); 94 | } 95 | 96 | return self::SUCCESS; 97 | } 98 | 99 | /** 100 | * @return SplFileInfo[] 101 | */ 102 | private function findFilesInPath(string $path): array 103 | { 104 | $finder = Finder::create() 105 | ->files() 106 | ->in([$path]) 107 | ->name('*.php') 108 | ->sortByName() 109 | ->filter(static fn (SplFileInfo $fileInfo): bool => 110 | // filter classes 111 | str_contains($fileInfo->getContents(), 'class ')); 112 | 113 | return iterator_to_array($finder->getIterator()); 114 | } 115 | 116 | private function resolveExpectedNamespace(string $namespaceRoot, SplFileInfo $fileInfo): string 117 | { 118 | $relativePathNamespace = str_replace('/', '\\', $fileInfo->getRelativePath()); 119 | if ($relativePathNamespace === '') { 120 | return $namespaceRoot; 121 | } 122 | 123 | return $namespaceRoot . '\\' . $relativePathNamespace; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Testing/MockWire.php: -------------------------------------------------------------------------------- 1 | $class 31 | * @param object[] $constructorDependencies 32 | * 33 | * @return TObject 34 | */ 35 | public static function create(string $class, array $constructorDependencies = []) 36 | { 37 | if (! class_exists($class)) { 38 | throw new InvalidArgumentException(sprintf( 39 | 'Class "%s" used in "%s" was not found. Make sure class exists', 40 | $class, 41 | __METHOD__ 42 | )); 43 | } 44 | 45 | // make sure all are objects 46 | foreach ($constructorDependencies as $constructorDependency) { 47 | if (is_object($constructorDependency)) { 48 | continue; 49 | } 50 | 51 | throw new InvalidArgumentException(sprintf( 52 | 'All constructor dependencies must be objects, but "%s" provided', 53 | gettype($constructorDependency) 54 | )); 55 | } 56 | 57 | if ($constructorDependencies === []) { 58 | throw new InvalidArgumentException(sprintf( 59 | 'Instead of using %s::create() with an empty arguments, use new %s() directly or fetch service from container', 60 | self::class, 61 | $class 62 | )); 63 | } 64 | 65 | $reflectionClass = new ReflectionClass($class); 66 | $constructorClassMethod = $reflectionClass->getConstructor(); 67 | 68 | if (! $constructorClassMethod instanceof ReflectionMethod) { 69 | // no dependencies, create it directly 70 | return new $class(); 71 | } 72 | 73 | $constructorMocks = []; 74 | 75 | foreach ($constructorClassMethod->getParameters() as $parameterReflection) { 76 | $constructorMocks[] = self::matchPassedMockOrCreate($constructorDependencies, $parameterReflection); 77 | } 78 | 79 | return new $class(...$constructorMocks); 80 | } 81 | 82 | /** 83 | * @param object[] $constructorDependencies 84 | */ 85 | private static function matchPassedMockOrCreate( 86 | array $constructorDependencies, 87 | ReflectionParameter $reflectionParameter 88 | ): object { 89 | if (! $reflectionParameter->getType() instanceof ReflectionNamedType) { 90 | throw new InvalidArgumentException(sprintf( 91 | 'Only typed parameters can be automocked. Provide the typehint for "%s" param', 92 | $reflectionParameter->getName() 93 | )); 94 | } 95 | 96 | $parameterType = $reflectionParameter->getType() 97 | ->getName(); 98 | 99 | foreach ($constructorDependencies as $constructorDependency) { 100 | if ($constructorDependency instanceof MockObject) { 101 | $originalClassName = get_parent_class($constructorDependency); 102 | 103 | // does it match with current reflection parameters? 104 | if ($parameterType === $originalClassName) { 105 | return $constructorDependency; 106 | } 107 | } 108 | 109 | // is bare object type equal to reflection type? 110 | if ($constructorDependency::class === $parameterType) { 111 | return $constructorDependency; 112 | } 113 | } 114 | 115 | // fallback to directly created mock 116 | // support for PHPUnit 10 and 9 117 | $testCaseReflectionClass = new ReflectionClass(TestCase::class); 118 | $testCaseConstructor = $testCaseReflectionClass->getConstructor(); 119 | if ($testCaseConstructor instanceof ReflectionMethod && $testCaseConstructor->getNumberOfRequiredParameters() > 0) { 120 | $phpunitMocker = new PHPUnitMocker('testName'); 121 | } else { 122 | $phpunitMocker = new PHPUnitMocker(); 123 | } 124 | 125 | return $phpunitMocker->create($reflectionParameter->getType()->getName()); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Command/SplitSymfonyConfigToPerPackageCommand.php: -------------------------------------------------------------------------------- 1 | printerStandard = new Standard(); 36 | 37 | parent::__construct(); 38 | } 39 | 40 | protected function configure(): void 41 | { 42 | $this->setName('split-config-per-package'); 43 | $this->setDescription( 44 | 'Split Symfony configs that contains many extension() calls to /packages directory with config per package' 45 | ); 46 | 47 | $this->addArgument('config-path', InputArgument::REQUIRED, 'Path to the config file'); 48 | $this->addOption('output-dir', null, InputOption::VALUE_REQUIRED, 'Directory to save the split config files'); 49 | } 50 | 51 | protected function execute(InputInterface $input, OutputInterface $output): int 52 | { 53 | $configPath = $input->getArgument('config-path'); 54 | $outputDir = $input->getOption('output-dir'); 55 | 56 | Assert::fileExists($configPath); 57 | Assert::notEmpty($outputDir); 58 | 59 | $stmts = $this->parseFilePathToStmts($configPath); 60 | 61 | $symfonyExtensionMethodCalls = $this->extractSymfonyExtensionMethodCalls($stmts); 62 | 63 | if ($symfonyExtensionMethodCalls === []) { 64 | $this->symfonyStyle->warning('No extension() method calls found'); 65 | 66 | return self::SUCCESS; 67 | } 68 | 69 | foreach ($symfonyExtensionMethodCalls as $symfonyExtensionMethodCall) { 70 | $extensionNameString = $symfonyExtensionMethodCall->getArgs()[0] 71 | ->value; 72 | if (! $extensionNameString instanceof String_) { 73 | throw new ShouldNotHappenException(); 74 | } 75 | 76 | $configStmts = $this->splitConfigClosureFactory->createStmts($symfonyExtensionMethodCall); 77 | $splitConfigFileContents = $this->printerStandard->prettyPrintFile($configStmts); 78 | 79 | $splitConfigFilePath = $outputDir . '/' . $extensionNameString->value . '.php'; 80 | 81 | FileSystem::write($splitConfigFilePath, $splitConfigFileContents, null); 82 | } 83 | 84 | // load packages from the output dir 85 | $addImportNodeTraverser = new NodeTraverser(); 86 | $addImportNodeTraverser->addVisitor(new AddImportConfigMethodCallNodeVisitor($outputDir)); 87 | $addImportNodeTraverser->traverse($stmts); 88 | 89 | // @todo print config back :) 90 | $cleanedConfigContents = $this->printerStandard->prettyPrintFile($stmts); 91 | FileSystem::write($configPath, $cleanedConfigContents, null); 92 | 93 | return 0; 94 | } 95 | 96 | /** 97 | * @return Stmt[] 98 | */ 99 | private function parseFilePathToStmts(string $configPath): array 100 | { 101 | $configContents = FileSystem::read($configPath); 102 | 103 | $parserFactory = new ParserFactory(); 104 | $phpParser = $parserFactory->createForHostVersion(); 105 | 106 | /** @var Stmt[] $stmts */ 107 | $stmts = $phpParser->parse($configContents); 108 | 109 | $nodeTraverser = new NodeTraverser(); 110 | $nodeTraverser->addVisitor(new NameResolver()); 111 | $nodeTraverser->traverse($stmts); 112 | 113 | return $stmts; 114 | } 115 | 116 | /** 117 | * @param Stmt[] $stmts 118 | * @return MethodCall[] 119 | */ 120 | private function extractSymfonyExtensionMethodCalls(array $stmts): array 121 | { 122 | $addImportNodeTraverser = new NodeTraverser(); 123 | 124 | $extractSymfonyExtensionCallNodeVisitor = new ExtractSymfonyExtensionCallNodeVisitor(); 125 | $addImportNodeTraverser->addVisitor($extractSymfonyExtensionCallNodeVisitor); 126 | $addImportNodeTraverser->traverse($stmts); 127 | 128 | return $extractSymfonyExtensionCallNodeVisitor->getExtensionMethodCalls(); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Command/FinalizeClassesCommand.php: -------------------------------------------------------------------------------- 1 | setName('finalize-classes'); 43 | $this->setAliases(['finalise', 'finalise-classes']); 44 | 45 | $this->setDescription('Finalize classes without children'); 46 | 47 | $this->addArgument('paths', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Directories to finalize'); 48 | 49 | $this->addOption( 50 | 'skip-mocked', 51 | null, 52 | InputOption::VALUE_NONE, 53 | 'Skip mocked classes as well (use only if unable to run bypass-finals package)' 54 | ); 55 | 56 | $this->addOption( 57 | 'skip-file', 58 | null, 59 | InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 60 | 'Skip file or files by path' 61 | ); 62 | 63 | $this->addOption( 64 | 'dry-run', 65 | null, 66 | InputOption::VALUE_NONE, 67 | 'Do no change anything, only list classes about to be finalized. If there are classes to finalize, it will exit with code 1. Useful for CI.' 68 | ); 69 | 70 | $this->addOption('no-progress', null, InputOption::VALUE_NONE, 'Do not show progress bar, only results'); 71 | } 72 | 73 | /** 74 | * @return self::FAILURE|self::SUCCESS 75 | */ 76 | protected function execute(InputInterface $input, OutputInterface $output): int 77 | { 78 | $paths = (array) $input->getArgument('paths'); 79 | $isDryRun = (bool) $input->getOption('dry-run'); 80 | $areMockedSkipped = (bool) $input->getOption('skip-mocked'); 81 | 82 | $this->symfonyStyle->title('1. Detecting parent and entity classes'); 83 | 84 | $skippedFiles = $input->getOption('skip-file'); 85 | $phpFileInfos = PhpFilesFinder::find($paths, $skippedFiles); 86 | 87 | $noProgress = (bool) $input->getOption('no-progress'); 88 | if (! $noProgress) { 89 | // double to count for both parent and entity resolver 90 | $stepRatio = $areMockedSkipped ? 3 : 2; 91 | 92 | $this->symfonyStyle->progressStart($stepRatio * count($phpFileInfos)); 93 | } 94 | 95 | $progressClosure = function () use ($noProgress): void { 96 | if ($noProgress) { 97 | return; 98 | } 99 | 100 | $this->symfonyStyle->progressAdvance(); 101 | }; 102 | 103 | $parentClassNames = $this->parentClassResolver->resolve($phpFileInfos, $progressClosure); 104 | $entityClassNames = $this->entityClassResolver->resolve($paths, $progressClosure); 105 | 106 | $mockedClassNames = $areMockedSkipped ? $this->mockedClassResolver->resolve($paths, $progressClosure) : []; 107 | 108 | if (! $noProgress) { 109 | $this->symfonyStyle->progressFinish(); 110 | } 111 | 112 | $this->symfonyStyle->writeln(sprintf( 113 | 'Found %d parent and %d entity classes', 114 | count($parentClassNames), 115 | count($entityClassNames) 116 | )); 117 | 118 | if ($areMockedSkipped) { 119 | $this->symfonyStyle->writeln(sprintf('Also %d mocked classes', count($mockedClassNames))); 120 | } 121 | 122 | $this->symfonyStyle->newLine(1); 123 | 124 | $this->symfonyStyle->title('2. Finalizing safe classes'); 125 | 126 | $excludedClasses = array_merge($parentClassNames, $entityClassNames, $mockedClassNames); 127 | $needsFinalizeAnalyzer = new NeedsFinalizeAnalyzer($excludedClasses, $this->cachedPhpParser); 128 | 129 | $finalizedFilePaths = []; 130 | 131 | foreach ($phpFileInfos as $phpFileInfo) { 132 | // should be file be finalize, is not and is not excluded? 133 | if (! $needsFinalizeAnalyzer->isNeeded($phpFileInfo->getRealPath())) { 134 | continue; 135 | } 136 | 137 | $finalizedContents = Strings::replace( 138 | $phpFileInfo->getContents(), 139 | self::NEWLINE_CLASS_START_REGEX, 140 | 'final $1class ' 141 | ); 142 | 143 | $finalizedFilePaths[] = PathHelper::relativeToCwd($phpFileInfo->getRealPath()); 144 | 145 | if ($isDryRun === false) { 146 | FileSystem::write($phpFileInfo->getRealPath(), $finalizedContents, null); 147 | } 148 | } 149 | 150 | if ($finalizedFilePaths === []) { 151 | $this->symfonyStyle->success('Nothing to finalize'); 152 | return self::SUCCESS; 153 | } 154 | 155 | $this->symfonyStyle->listing($finalizedFilePaths); 156 | 157 | $countFinalizedClasses = count($finalizedFilePaths); 158 | $pluralClassText = $countFinalizedClasses === 1 ? 'class' : 'classes'; 159 | 160 | // to make it fail in CI 161 | if ($isDryRun) { 162 | $this->symfonyStyle->error(sprintf( 163 | '%d %s can be finalized', 164 | $countFinalizedClasses, 165 | $pluralClassText, 166 | )); 167 | 168 | return self::FAILURE; 169 | } 170 | 171 | $this->symfonyStyle->success(sprintf( 172 | '%d %s finalized', 173 | $countFinalizedClasses, 174 | $pluralClassText, 175 | )); 176 | 177 | return self::SUCCESS; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/PhpParser/NodeVisitor/FindClassConstFetchNodeVisitor.php: -------------------------------------------------------------------------------- 1 | isAnonymous()) { 46 | return NodeTraverser::DONT_TRAVERSE_CURRENT_AND_CHILDREN; 47 | } 48 | 49 | $this->currentClass = $node; 50 | return null; 51 | } 52 | 53 | if (! $node instanceof ClassConstFetch) { 54 | return null; 55 | } 56 | 57 | // unable to resolve → skip 58 | if (! $node->class instanceof Name) { 59 | return null; 60 | } 61 | 62 | $className = $node->class->toString(); 63 | 64 | if ($node->name instanceof Expr) { 65 | // unable to resolve → skip 66 | return null; 67 | } 68 | 69 | $constantName = $node->name->toString(); 70 | 71 | // always public magic 72 | if ($constantName === 'class') { 73 | return null; 74 | } 75 | 76 | if ($className === StaticAccessor::SELF) { 77 | Assert::isInstanceOf($this->currentClass, Class_::class); 78 | 79 | $currentClassName = $this->getClassName(); 80 | if ($this->isCurrentClassConstant($this->currentClass, $constantName)) { 81 | $this->classConstantFetches[] = new CurrentClassConstantFetch($currentClassName, $constantName); 82 | return $node; 83 | } 84 | 85 | // check if parent class is vendor 86 | if ($this->currentClass->extends instanceof Name) { 87 | $parentClassName = $this->currentClass->extends->toString(); 88 | if ($this->isVendorClassName($parentClassName)) { 89 | return null; 90 | } 91 | } 92 | 93 | $this->classConstantFetches[] = new ParentClassConstantFetch($currentClassName, $constantName); 94 | return $node; 95 | } 96 | 97 | if ($className === StaticAccessor::STATIC) { 98 | Assert::isInstanceOf($this->currentClass, Class_::class); 99 | 100 | $currentClassName = $this->getClassName(); 101 | 102 | if ($this->isCurrentClassConstant($this->currentClass, $constantName)) { 103 | $this->classConstantFetches[] = new CurrentClassConstantFetch($currentClassName, $constantName); 104 | return $node; 105 | } 106 | 107 | $this->classConstantFetches[] = new StaticClassConstantFetch($currentClassName, $constantName); 108 | return $node; 109 | } 110 | 111 | if ($this->doesClassExist($className)) { 112 | // is class from /vendor? we can skip it 113 | if ($this->isVendorClassName($className)) { 114 | return null; 115 | } 116 | 117 | // is vendor fetch? skip 118 | $this->classConstantFetches[] = new ExternalClassAccessConstantFetch($className, $constantName); 119 | return null; 120 | } 121 | 122 | throw new NotImplementedYetException(sprintf('Class "%s" was not found', $className)); 123 | } 124 | 125 | public function leaveNode(Node $node): ?Node 126 | { 127 | if (! $node instanceof Class_) { 128 | return null; 129 | } 130 | 131 | // we've left class, lets reset its value 132 | $this->currentClass = null; 133 | return $node; 134 | } 135 | 136 | /** 137 | * @return ClassConstantFetchInterface[] 138 | */ 139 | public function getClassConstantFetches(): array 140 | { 141 | return $this->classConstantFetches; 142 | } 143 | 144 | private function isVendorClassName(string $className): bool 145 | { 146 | if (! $this->doesClassExist($className)) { 147 | throw new ShouldNotHappenException(sprintf('Class "%s" could not be found', $className)); 148 | } 149 | 150 | $reflectionClass = new ReflectionClass($className); 151 | return str_contains((string) $reflectionClass->getFileName(), 'vendor'); 152 | } 153 | 154 | private function isCurrentClassConstant(Class_ $currentClass, string $constantName): bool 155 | { 156 | foreach ($currentClass->getConstants() as $classConstant) { 157 | foreach ($classConstant->consts as $const) { 158 | if ($const->name->toString() === $constantName) { 159 | return true; 160 | } 161 | } 162 | } 163 | 164 | return false; 165 | } 166 | 167 | private function getClassName(): string 168 | { 169 | if (! $this->currentClass instanceof Class_) { 170 | throw new ShouldNotHappenException('Class_ node is missing'); 171 | } 172 | 173 | $namespaceName = $this->currentClass->namespacedName; 174 | if (! $namespaceName instanceof Name) { 175 | throw new ShouldNotHappenException(); 176 | } 177 | 178 | return $namespaceName->toString(); 179 | } 180 | 181 | private function doesClassExist(string $className): bool 182 | { 183 | if (class_exists($className)) { 184 | return true; 185 | } 186 | 187 | if (interface_exists($className)) { 188 | return true; 189 | } 190 | 191 | return trait_exists($className); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/Command/PrivatizeConstantsCommand.php: -------------------------------------------------------------------------------- 1 | setName('privatize-constants'); 41 | 42 | $this->addArgument( 43 | 'sources', 44 | InputArgument::REQUIRED | InputArgument::IS_ARRAY, 45 | 'One or more paths to check, include tests directory as well' 46 | ); 47 | 48 | $this->addOption( 49 | 'exclude-path', 50 | null, 51 | InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 52 | 'Path to exclude' 53 | ); 54 | 55 | $this->addOption('debug', null, InputOption::VALUE_NONE, 'Debug output'); 56 | 57 | $this->setDescription('Make class constants private if not used outside in PHP, Twig and YAML files'); 58 | } 59 | 60 | /** 61 | * @return Command::* 62 | */ 63 | protected function execute(InputInterface $input, OutputInterface $output): int 64 | { 65 | $sources = (array) $input->getArgument('sources'); 66 | $excludedPaths = (array) $input->getOption('exclude-path'); 67 | $isDebug = (bool) $input->getOption('debug'); 68 | 69 | $phpFileInfos = PhpFilesFinder::find($sources, $excludedPaths); 70 | if ($phpFileInfos === []) { 71 | $this->symfonyStyle->warning('No PHP files found in provided paths'); 72 | 73 | return self::SUCCESS; 74 | } 75 | 76 | $this->symfonyStyle->title('Finding class const fetches...'); 77 | 78 | $progressBar = $this->symfonyStyle->createProgressBar(count($phpFileInfos)); 79 | $phpClassConstantFetches = $this->classConstantFetchFinder->find($phpFileInfos, $progressBar, $isDebug); 80 | 81 | // find usage in twig files 82 | $twigClassConstantFetches = $this->twigTemplateConstantExtractor->extractFromDirs($sources); 83 | $yamlClassConstantFetches = $this->yamlConfigConstantExtractor->extractFromDirs($sources); 84 | 85 | $classConstantFetches = array_merge( 86 | $phpClassConstantFetches, 87 | $twigClassConstantFetches, 88 | $yamlClassConstantFetches 89 | ); 90 | 91 | $this->symfonyStyle->newLine(2); 92 | $this->symfonyStyle->success(sprintf('Found %d class constant fetches', count($classConstantFetches))); 93 | $this->symfonyStyle->success(sprintf('Found %d constants in Twig templates', count($twigClassConstantFetches))); 94 | $this->symfonyStyle->success(sprintf('Found %d constants in YAML configs', count($yamlClassConstantFetches))); 95 | 96 | $this->symfonyStyle->newLine(2); 97 | 98 | $this->symfonyStyle->title('Changing class constant visibility based on use...'); 99 | 100 | $visibilityChangeStats = new VisibilityChangeStats(); 101 | 102 | // go file by file and deal with public + protected constants 103 | foreach ($phpFileInfos as $phpFileInfo) { 104 | $currentVisibilityChangeStats = $this->processFileInfo($phpFileInfo, $classConstantFetches); 105 | $visibilityChangeStats->merge($currentVisibilityChangeStats); 106 | } 107 | 108 | if (! $visibilityChangeStats->hasAnyChange()) { 109 | $this->symfonyStyle->warning('No constants were privatized'); 110 | return self::SUCCESS; 111 | } 112 | 113 | $this->symfonyStyle->newLine(2); 114 | 115 | $this->symfonyStyle->success( 116 | sprintf('Totally %d constants were made private', $visibilityChangeStats->getPrivateCount()) 117 | ); 118 | 119 | return self::SUCCESS; 120 | } 121 | 122 | /** 123 | * @param ClassConstantFetchInterface[] $classConstantFetches 124 | */ 125 | private function processFileInfo(SplFileInfo $phpFileInfo, array $classConstantFetches): VisibilityChangeStats 126 | { 127 | $visibilityChangeStats = new VisibilityChangeStats(); 128 | 129 | $classConstants = $this->classConstFinder->find($phpFileInfo->getRealPath()); 130 | if ($classConstants === []) { 131 | return $visibilityChangeStats; 132 | } 133 | 134 | foreach ($classConstants as $classConstant) { 135 | if ($this->isClassConstantUsedPublicly($classConstantFetches, $classConstant)) { 136 | // keep it public 137 | continue; 138 | } 139 | 140 | // make private 141 | $changedFileContents = Strings::replace( 142 | $phpFileInfo->getContents(), 143 | '#((private|public|protected)\s+)?const\s+' . $classConstant->getConstantName() . '#', 144 | 'private const ' . $classConstant->getConstantName() 145 | ); 146 | FileSystem::write($phpFileInfo->getRealPath(), $changedFileContents, null); 147 | 148 | $this->symfonyStyle->writeln( 149 | sprintf('Constant "%s" changed to private', $classConstant->getConstantName()) 150 | ); 151 | $visibilityChangeStats->countPrivate(); 152 | } 153 | 154 | return $visibilityChangeStats; 155 | } 156 | 157 | /** 158 | * @param ClassConstantFetchInterface[] $classConstantFetches 159 | */ 160 | private function isClassConstantUsedPublicly(array $classConstantFetches, ClassConstant $classConstant): bool 161 | { 162 | foreach ($classConstantFetches as $classConstantFetch) { 163 | if (! $classConstantFetch->isClassConstantMatch($classConstant)) { 164 | continue; 165 | } 166 | 167 | // used only locally, can stay private 168 | if ($classConstantFetch instanceof CurrentClassConstantFetch) { 169 | continue; 170 | } 171 | 172 | // used externally, make public 173 | return true; 174 | } 175 | 176 | return false; 177 | } 178 | } 179 | --------------------------------------------------------------------------------