├── 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 |
--------------------------------------------------------------------------------