diff --git a/.gitignore b/.gitignore index 1d763c8d682e..bb4b79c706b5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ uuid-migration.json # compiler /compiler/composer.lock /compiler/vendor -/tmp \ No newline at end of file +/tmp + +# from "scan-fatal-errors" command +rector-types.yaml \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 17bf1d04df6c..a4f0ae4ef447 100644 --- a/.travis.yml +++ b/.travis.yml @@ -79,6 +79,11 @@ jobs: name: Rector script: - composer rector + - + php: 7.3 + name: "Scan Fatal Errors" + script: + - bin/rector scan-fatal-errors tests/Source/FatalErrors - name: Documentation diff --git a/phpstan.neon b/phpstan.neon index 3bbf63b0f1e8..74dfab1812bb 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -237,3 +237,6 @@ parameters: - message: '#Method Rector\\Symfony\\Rector\\FrameworkBundle\\AbstractToConstructorInjectionRector\:\:getServiceTypeFromMethodCallArgument\(\) should return PHPStan\\Type\\Type but returns PHPStan\\Type\\Type\|null#' path: packages/Symfony/src/Rector/FrameworkBundle/AbstractToConstructorInjectionRector.php + + - '#Parameter \#1 \$error_handler of function set_error_handler expects \(callable\(int, string, string, int, array\)\: bool\)\|null, Closure\(int, string\)\: void given#' + - '#Parameter \#1 \$source of method Rector\\Scan\\ErrorScanner\:\:scanSource\(\) expects array, array\|string\|null given#' diff --git a/rector.yaml b/rector.yaml index 4b55f4cd0dfd..a6b83e0bffed 100644 --- a/rector.yaml +++ b/rector.yaml @@ -5,7 +5,6 @@ parameters: exclude_paths: - "/Fixture/" - "/Expected/" - - "/Source/" - "packages/Symfony/src/Bridge/DefaultAnalyzedSymfonyApplicationContainer.php" - "src/Testing/PHPUnit/AbstractRectorTestCase.php" - "packages/Php/tests/Rector/Name/ReservedObjectRector/*" @@ -15,66 +14,3 @@ parameters: # so Rector code is still PHP 7.2 compatible php_version_features: '7.2' - - rector_recipe: - # run "bin/rector create" to create a new Rector + tests from this config - package: "NetteToSymfony" - name: "FormControlToControllerAndFormTypeRector" - node_types: - # put main node first, it is used to create namespace - - "Class_" - description: "Change Form that extends Control to Controller and decoupled FormType" - code_before: | - addText('name', 'Your name'); - - $form->onSuccess[] = [$this, 'processForm']; - } - - public function processForm(Form $form) - { - // process me - } - } - - code_after: | - createForm(SomeFormType::class); - $form->handleRequest($request); - - if ($form->isSuccess() && $form->isValid()) { - // process me - } - } - } - - class SomeFormType extends \Symfony\Component\Form\AbstractType - { - public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, array $options) - { - $builder->add('name', \Symfony\Component\Form\Extension\Core\Type\TextType::class, [ - 'label' => 'Your name' - ]); - } - } - - source: # e.g. link to RFC or headline in upgrade guide, 1 or more in the list - - "https://symfony.com/doc/current/forms.html#creating-form-classes" - set: "nette-to-symfony" # e.g. symfony30, target config to append this rector to diff --git a/src/Configuration/Option.php b/src/Configuration/Option.php index 29554fbe1f32..d132400d77bd 100644 --- a/src/Configuration/Option.php +++ b/src/Configuration/Option.php @@ -65,4 +65,9 @@ final class Option * @var string */ public const SYMFONY_CONTAINER_XML_PATH_PARAMETER = 'symfony_container_xml_path'; + + /** + * @var string + */ + public const DUMP = 'dump'; } diff --git a/src/Console/Command/ScanFatalErrorsCommand.php b/src/Console/Command/ScanFatalErrorsCommand.php new file mode 100644 index 000000000000..8f03b40e02a2 --- /dev/null +++ b/src/Console/Command/ScanFatalErrorsCommand.php @@ -0,0 +1,108 @@ +symfonyStyle = $symfonyStyle; + $this->scannedErrorToRectorResolver = $scannedErrorToRectorResolver; + $this->errorScanner = $errorScanner; + $this->yamlPrinter = $yamlPrinter; + + parent::__construct(); + } + + protected function configure(): void + { + $this->setName(CommandNaming::classToName(self::class)); + + $this->setDescription('Scan for fatal type errors and dumps config that fixes it'); + + $this->addArgument( + Option::SOURCE, + InputArgument::REQUIRED | InputArgument::IS_ARRAY, + 'Path to file/directory to process' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + /** @var string[] $source */ + $source = $input->getArgument(Option::SOURCE); + $errors = $this->errorScanner->scanSource($source); + + if ($errors === []) { + $this->symfonyStyle->success('No fatal errors found'); + return ShellCode::SUCCESS; + } + + $this->symfonyStyle->section(sprintf('Found %d Errors', count($errors))); + foreach ($errors as $error) { + $this->symfonyStyle->note($error); + } + + $rectorConfiguration = $this->scannedErrorToRectorResolver->processErrors($errors); + if ($rectorConfiguration === []) { + $this->symfonyStyle->success('No fatal errors found'); + return ShellCode::SUCCESS; + } + + $this->yamlPrinter->printYamlToFile($rectorConfiguration, self::RECTOR_TYPES_YAML); + + $this->symfonyStyle->note(sprintf('New config with types was created in "%s"', self::RECTOR_TYPES_YAML)); + + $this->symfonyStyle->success(sprintf( + 'Now run Rector to refactor your code:%svendor/bin/rector p %s --config %s', + PHP_EOL . PHP_EOL, + implode(' ', $source), + self::RECTOR_TYPES_YAML + )); + + return Shell::CODE_SUCCESS; + } +} diff --git a/src/Console/Command/ShowCommand.php b/src/Console/Command/ShowCommand.php index 146064014a93..d2b56f394388 100644 --- a/src/Console/Command/ShowCommand.php +++ b/src/Console/Command/ShowCommand.php @@ -7,11 +7,11 @@ use Rector\Console\Shell; use Rector\Contract\Rector\RectorInterface; use Rector\Php\TypeAnalyzer; +use Rector\Yaml\YamlPrinter; use ReflectionClass; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Yaml\Yaml; use Symplify\PackageBuilder\Console\Command\CommandNaming; final class ShowCommand extends AbstractCommand @@ -31,16 +31,26 @@ final class ShowCommand extends AbstractCommand */ private $typeAnalyzer; + /** + * @var YamlPrinter + */ + private $yamlPrinter; + /** * @param RectorInterface[] $rectors */ - public function __construct(SymfonyStyle $symfonyStyle, array $rectors, TypeAnalyzer $typeAnalyzer) - { + public function __construct( + SymfonyStyle $symfonyStyle, + array $rectors, + TypeAnalyzer $typeAnalyzer, + YamlPrinter $yamlPrinter + ) { $this->symfonyStyle = $symfonyStyle; $this->rectors = $rectors; + $this->typeAnalyzer = $typeAnalyzer; + $this->yamlPrinter = $yamlPrinter; parent::__construct(); - $this->typeAnalyzer = $typeAnalyzer; } protected function configure(): void @@ -60,7 +70,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } - $configurationYamlContent = Yaml::dump($configuration, 10, 4, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $configurationYamlContent = $this->yamlPrinter->printYamlToString($configuration); $lines = explode(PHP_EOL, $configurationYamlContent); $indentedContent = ' ' . implode(PHP_EOL . ' ', $lines); diff --git a/src/FileSystem/FilesFinder.php b/src/FileSystem/FilesFinder.php index 67a9aa81d878..0d776892952e 100644 --- a/src/FileSystem/FilesFinder.php +++ b/src/FileSystem/FilesFinder.php @@ -80,8 +80,10 @@ public function findInDirectoriesAndFiles(array $source, array $suffixes, bool $ if ($matchDiff) { $gitDiffFiles = $this->getGitDiff(); - $smartFileInfos = array_filter($smartFileInfos, function ($splFile) use ($gitDiffFiles): bool { - return in_array($splFile->getRealPath(), $gitDiffFiles, true); + $smartFileInfos = array_filter($smartFileInfos, function (SmartFileInfo $fileInfo) use ( + $gitDiffFiles + ): bool { + return in_array($fileInfo->getRealPath(), $gitDiffFiles, true); }); $smartFileInfos = array_values($smartFileInfos); diff --git a/src/Scan/ErrorScanner.php b/src/Scan/ErrorScanner.php new file mode 100644 index 000000000000..6c2746fc19dd --- /dev/null +++ b/src/Scan/ErrorScanner.php @@ -0,0 +1,112 @@ +filesFinder = $filesFinder; + $this->symfonyStyle = $symfonyStyle; + } + + /** + * @param string[] $source + * @return string[] + */ + public function scanSource(array $source): array + { + $this->setErrorHandler(); + + $fileInfos = $this->filesFinder->findInDirectoriesAndFiles($source, ['php']); + + $commandLine = 'include "vendor/autoload.php";'; + + foreach ($fileInfos as $fileInfo) { + $currentCommandLine = $commandLine . PHP_EOL; + $currentCommandLine .= sprintf('include "%s";', $fileInfo->getRelativeFilePathFromCwd()); + + $currentCommandLine = sprintf("php -r '%s'", $currentCommandLine); + + $this->symfonyStyle->note('Running PHP in sub-process: ' . $currentCommandLine); + + $process = Process::fromShellCommandline($currentCommandLine); + $process->run(); + + if ($process->isSuccessful()) { + continue; + } + + $this->errors[] = trim($process->getErrorOutput()); + } + + $this->restoreErrorHandler(); + + return $this->errors; + } + + public function shutdown_function(): void + { + $error = error_get_last(); + //check if it's a core/fatal error, otherwise it's a normal shutdown + if ($error === null) { + return; + } + + if (! in_array( + $error['type'], + [ + E_ERROR, + E_PARSE, + E_CORE_ERROR, + E_CORE_WARNING, + E_COMPILE_ERROR, + E_COMPILE_WARNING, + E_RECOVERABLE_ERROR, + ], true + )) { + return; + } + + print_r($error); + } + + /** + * @see https://www.php.net/manual/en/function.set-error-handler.php + * @see https://stackoverflow.com/a/36638910/1348344 + */ + private function setErrorHandler(): void + { + register_shutdown_function([$this, 'shutdown_function']); + + set_error_handler(function (int $num, string $error): void { + $this->errors[] = $error; + }); + } + + private function restoreErrorHandler(): void + { + restore_error_handler(); + } +} diff --git a/src/Scan/ScannedErrorToRectorResolver.php b/src/Scan/ScannedErrorToRectorResolver.php new file mode 100644 index 000000000000..2ecead7843cd --- /dev/null +++ b/src/Scan/ScannedErrorToRectorResolver.php @@ -0,0 +1,175 @@ +\w.*?) should be compatible with (?\w.*?)$#'; + + /** + * @see https://regex101.com/r/D6Z5Uq/1/ + * @var string + */ + private const INCOMPATIBLE_RETURN_TYPE_PATTERN = '#Declaration of (?\w.*?) must be compatible with (?\w.*?)$#'; + + /** + * @see https://regex101.com/r/RbJy9h/8/ + * @var string + */ + private const CLASS_METHOD_ARGUMENTS_PATTERN = '#(?.*?)::(?.*?)\((?.*?)\)(:\s?(?\w+))?#'; + + /** + * @see https://regex101.com/r/RbJy9h/5 + * @var string + */ + private const ARGUMENTS_PATTERN = '#(\b(?\w.*?)?\b )?\$(?\w+)#sm'; + + /** + * @var mixed[] + */ + private $paramChanges = []; + + /** + * @var mixed[] + */ + private $returnChanges = []; + + /** + * @param string[] $errors + * @return mixed[] + */ + public function processErrors(array $errors): array + { + $this->paramChanges = []; + + foreach ($errors as $fatalError) { + $match = Strings::match($fatalError, self::INCOMPATIBLE_PARAM_TYPE_PATTERN); + if ($match) { + $this->processIncompatibleParamTypeMatch($match); + continue; + } + + $match = Strings::match($fatalError, self::INCOMPATIBLE_RETURN_TYPE_PATTERN); + if ($match) { + $this->processIncompatibleReturnTypeMatch($match); + } + } + + $config = []; + if ($this->paramChanges !== []) { + $config['services'][AddParamTypeDeclarationRector::class]['$typehintForParameterByMethodByClass'] = $this->paramChanges; + } + + if ($this->returnChanges !== []) { + $config['services'][AddReturnTypeDeclarationRector::class]['$typehintForMethodByClass'] = $this->returnChanges; + } + + return $config; + } + + private function createScannedMethod(string $classMethodWithArgumentsDescription): ClassMethodWithArguments + { + $match = Strings::match($classMethodWithArgumentsDescription, self::CLASS_METHOD_ARGUMENTS_PATTERN); + if (! $match) { + throw new NotImplementedException(); + } + + $arguments = $this->createArguments((string) $match['arguments']); + + return new ClassMethodWithArguments($match['class'], $match['method'], $arguments, $match['return_type'] ?? ''); + } + + /** + * @return Argument[] + */ + private function createArguments(string $argumentsDescription): array + { + // 0 arguments + if ($argumentsDescription === '') { + return []; + } + + $arguments = []; + $argumentDescriptions = Strings::split($argumentsDescription, '#\b,\b#'); + foreach ($argumentDescriptions as $position => $argumentDescription) { + $match = Strings::match((string) $argumentDescription, self::ARGUMENTS_PATTERN); + if (! $match) { + throw new NotImplementedException(); + } + + $arguments[] = new Argument($match['name'], $position, $match['type'] ?? ''); + } + + return $arguments; + } + + private function collectClassMethodParamDifferences( + ClassMethodWithArguments $scannedMethod, + ClassMethodWithArguments $shouldBeMethod + ): void { + foreach ($scannedMethod->getArguments() as $scannedMethodArgument) { + $shouldBeArgument = $shouldBeMethod->getArgumentByPosition($scannedMethodArgument->getPosition()); + + if ($shouldBeArgument === null) { + throw new NotImplementedException(); + } + + // types are identical, nothing to change + if ($scannedMethodArgument->getType() === $shouldBeArgument->getType()) { + continue; + } + + $this->paramChanges[$scannedMethod->getClass()][$scannedMethod->getMethod()][$scannedMethodArgument->getPosition()] = $shouldBeArgument->getType(); + } + } + + private function processIncompatibleParamTypeMatch(array $match): void + { + if (! Strings::contains($match['current'], '::')) { + // probably a function? + throw new NotImplementedException(); + } + + $scannedMethod = $this->createScannedMethod($match['current']); + $shouldBeMethod = $this->createScannedMethod($match['should_be']); + + $this->collectClassMethodParamDifferences($scannedMethod, $shouldBeMethod); + } + + private function processIncompatibleReturnTypeMatch(array $match): void + { + if (! Strings::contains($match['current'], '::')) { + // probably a function? + throw new NotImplementedException(); + } + + $scannedMethod = $this->createScannedMethod($match['current']); + $shouldBeMethod = $this->createScannedMethod($match['should_be']); + + $this->collectClassMethodReturnDifferences($scannedMethod, $shouldBeMethod); + } + + private function collectClassMethodReturnDifferences( + ClassMethodWithArguments $scannedMethod, + ClassMethodWithArguments $shouldBeMethod + ): void { + if ($scannedMethod->getReturnType() === $shouldBeMethod->getReturnType()) { + return; + } + + $this->returnChanges[$scannedMethod->getClass()][$scannedMethod->getMethod()] = $shouldBeMethod->getReturnType(); + } +} diff --git a/src/ValueObject/Scan/Argument.php b/src/ValueObject/Scan/Argument.php new file mode 100644 index 000000000000..e7376440b442 --- /dev/null +++ b/src/ValueObject/Scan/Argument.php @@ -0,0 +1,45 @@ +position = $position; + $this->name = $name; + $this->type = $type; + } + + public function getPosition(): int + { + return $this->position; + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): string + { + return $this->type; + } +} diff --git a/src/ValueObject/Scan/ClassMethodWithArguments.php b/src/ValueObject/Scan/ClassMethodWithArguments.php new file mode 100644 index 000000000000..fd195e86ae8e --- /dev/null +++ b/src/ValueObject/Scan/ClassMethodWithArguments.php @@ -0,0 +1,75 @@ +class = $class; + $this->method = $method; + $this->arguments = $arguments; + $this->returnType = $returnType; + } + + public function getClass(): string + { + return $this->class; + } + + public function getMethod(): string + { + return $this->method; + } + + /** + * @return Argument[] + */ + public function getArguments(): array + { + return $this->arguments; + } + + public function getArgumentByPosition(int $position): ?Argument + { + foreach ($this->arguments as $argument) { + if ($argument->getPosition() !== $position) { + continue; + } + + return $argument; + } + + return null; + } + + public function getReturnType(): string + { + return $this->returnType; + } +} diff --git a/src/Yaml/YamlPrinter.php b/src/Yaml/YamlPrinter.php new file mode 100644 index 000000000000..8ee7effcf641 --- /dev/null +++ b/src/Yaml/YamlPrinter.php @@ -0,0 +1,22 @@ +printYamlToString($yaml); + FileSystem::write($targetFile, $yamlContent); + } +} diff --git a/tests/Scan/ScannedErrorToRectorResolverTest.php b/tests/Scan/ScannedErrorToRectorResolverTest.php new file mode 100644 index 000000000000..995fabaa264e --- /dev/null +++ b/tests/Scan/ScannedErrorToRectorResolverTest.php @@ -0,0 +1,72 @@ +bootKernel(RectorKernel::class); + + $this->scannedErrorToRectorResolver = self::$container->get(ScannedErrorToRectorResolver::class); + } + + public function testParam(): void + { + $errors = []; + $errors[] = 'Declaration of Kedlubna\extendTest::add($message) should be compatible with Kedlubna\test::add(string $message = \'\')'; + + $rectorConfiguration = $this->scannedErrorToRectorResolver->processErrors($errors); + + $expectedConfiguration = [ + 'services' => [ + AddParamTypeDeclarationRector::class => [ + '$typehintForParameterByMethodByClass' => [ + 'Kedlubna\extendTest' => [ + 'add' => [ + 0 => 'string', + ], + ], + ], + ], + ], + ]; + + $this->assertSame($expectedConfiguration, $rectorConfiguration); + } + + public function testReturn(): void + { + $errors = []; + $errors[] = 'Declaration of AAA\extendTest::nothing() must be compatible with AAA\test::nothing(): void;'; + + $rectorConfiguration = $this->scannedErrorToRectorResolver->processErrors($errors); + + $expectedConfiguration = [ + 'services' => [ + AddReturnTypeDeclarationRector::class => [ + '$typehintForMethodByClass' => [ + 'AAA\extendTest' => [ + 'nothing' => 'void', + ], + ], + ], + ], + ]; + + $this->assertSame($expectedConfiguration, $rectorConfiguration); + } +} diff --git a/tests/Source/FatalErrors/First.php b/tests/Source/FatalErrors/First.php new file mode 100644 index 000000000000..e7ba371921cb --- /dev/null +++ b/tests/Source/FatalErrors/First.php @@ -0,0 +1,10 @@ +