Add ScanFatalErrors command

This commit is contained in:
TomasVotruba 2019-12-24 11:45:52 +01:00
parent 03fbc5b0f7
commit 3742dc4b44
17 changed files with 647 additions and 72 deletions

5
.gitignore vendored
View File

@ -21,4 +21,7 @@ uuid-migration.json
# compiler
/compiler/composer.lock
/compiler/vendor
/tmp
/tmp
# from "scan-fatal-errors" command
rector-types.yaml

5
abz/First.php Normal file
View File

@ -0,0 +1,5 @@
<?php declare(strict_types=1);
class First extends Test {
public function remove() {}
}

5
abz/Second.php Normal file
View File

@ -0,0 +1,5 @@
<?php declare(strict_types=1);
class Second extends Test {
public function remove() {}
}

5
abz/Test.php Normal file
View File

@ -0,0 +1,5 @@
<?php declare(strict_types=1);
class Test {
public function remove(): void {}
}

View File

@ -174,7 +174,8 @@
"tests/Rector/Namespace_/PseudoNamespaceToNamespaceRector/Source",
"tests/Issues/Issue1243/Source",
"packages/Autodiscovery/tests/Rector/FileSystem/MoveInterfacesToContractNamespaceDirectoryRector/Expected",
"packages/Autodiscovery/tests/Rector/FileSystem/MoveServicesBySuffixToDirectoryRector/Expected"
"packages/Autodiscovery/tests/Rector/FileSystem/MoveServicesBySuffixToDirectoryRector/Expected",
"abz"
],
"files": [
"packages/DeadCode/tests/Rector/MethodCall/RemoveDefaultArgumentValueRector/Source/UserDefined.php",

View File

@ -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<string\>, array<string\>\|string\|null given#'

View File

@ -15,66 +15,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: |
<?php
use Nette\Application\UI\Form;
use Nette\Application\UI\Control;
class SomeForm extends Control
{
public function createComponentForm()
{
$form = new Form();
$form->addText('name', 'Your name');
$form->onSuccess[] = [$this, 'processForm'];
}
public function processForm(Form $form)
{
// process me
}
}
code_after: |
<?php
class SomeFormController extends AbstractController
{
/**
* @Route(...)
*/
public function actionSomeForm(Request $request): Response
{
$form = $this->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

View File

@ -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';
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Rector\Console\Command;
use Rector\Configuration\Option;
use Rector\Console\Shell;
use Rector\Scan\ErrorScanner;
use Rector\Scan\ScannedErrorToRectorResolver;
use Rector\Yaml\YamlPrinter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symplify\PackageBuilder\Console\Command\CommandNaming;
use Symplify\PackageBuilder\Console\ShellCode;
final class ScanFatalErrorsCommand extends AbstractCommand
{
/**
* @var string
*/
private const RECTOR_TYPES_YAML = 'rector-types.yaml';
/**
* @var SymfonyStyle
*/
private $symfonyStyle;
/**
* @var ScannedErrorToRectorResolver
*/
private $scannedErrorToRectorResolver;
/**
* @var ErrorScanner
*/
private $errorScanner;
/**
* @var YamlPrinter
*/
private $yamlPrinter;
public function __construct(
SymfonyStyle $symfonyStyle,
ScannedErrorToRectorResolver $scannedErrorToRectorResolver,
ErrorScanner $errorScanner,
YamlPrinter $yamlPrinter
) {
$this->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;
}
}

View File

@ -7,11 +7,11 @@ namespace Rector\Console\Command;
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 @@ final class ShowCommand extends AbstractCommand
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);

View File

@ -80,8 +80,10 @@ final class FilesFinder
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);

102
src/Scan/ErrorScanner.php Normal file
View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Rector\Scan;
use Rector\FileSystem\FilesFinder;
use Symfony\Component\Process\Process;
final class ErrorScanner
{
/**
* @var string[]
*/
private $errors = [];
/**
* @var FilesFinder
*/
private $filesFinder;
public function __construct(FilesFinder $filesFinder)
{
$this->filesFinder = $filesFinder;
}
/**
* @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);
$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();
}
}

View File

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Rector\Scan;
use Nette\Utils\Strings;
use Rector\Exception\NotImplementedException;
use Rector\Rector\ClassMethod\AddReturnTypeDeclarationRector;
use Rector\TypeDeclaration\Rector\ClassMethod\AddParamTypeDeclarationRector;
use Rector\ValueObject\Scan\Argument;
use Rector\ValueObject\Scan\ClassMethodWithArguments;
final class ScannedErrorToRectorResolver
{
/**
* @see https://regex101.com/r/RbJy9h/1/
* @var string
*/
private const INCOMPATIBLE_PARAM_TYPE_PATTERN = '#Declaration of (?<current>\w.*?) should be compatible with (?<should_be>\w.*?)$#';
/**
* @see https://regex101.com/r/D6Z5Uq/1/
* @var string
*/
private const INCOMPATIBLE_RETURN_TYPE_PATTERN = '#Declaration of (?<current>\w.*?) must be compatible with (?<should_be>\w.*?)$#';
/**
* @see https://regex101.com/r/RbJy9h/8/
* @var string
*/
private const CLASS_METHOD_ARGUMENTS_PATTERN = '#(?<class>.*?)::(?<method>.*?)\((?<arguments>.*?)\)(:\s?(?<return_type>\w+))?#';
/**
* @see https://regex101.com/r/RbJy9h/5
* @var string
*/
private const ARGUMENTS_PATTERN = '#(\b(?<type>\w.*?)?\b )?\$(?<name>\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();
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Rector\ValueObject\Scan;
final class Argument
{
/**
* @var int
*/
private $position;
/**
* @var string
*/
private $name;
/**
* @var string
*/
private $type;
public function __construct(string $name, int $position, string $type = '')
{
$this->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;
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Rector\ValueObject\Scan;
final class ClassMethodWithArguments
{
/**
* @var string
*/
private $class;
/**
* @var string
*/
private $method;
/**
* @var Argument[]
*/
private $arguments = [];
/**
* @var string
*/
private $returnType;
/**
* @param Argument[] $arguments
*/
public function __construct(string $class, string $method, array $arguments, string $returnType)
{
$this->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;
}
}

22
src/Yaml/YamlPrinter.php Normal file
View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Rector\Yaml;
use Nette\Utils\FileSystem;
use Symfony\Component\Yaml\Yaml;
final class YamlPrinter
{
public function printYamlToString(array $yaml): string
{
return Yaml::dump($yaml, 10, 4, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
}
public function printYamlToFile(array $yaml, string $targetFile): void
{
$yamlContent = $this->printYamlToString($yaml);
FileSystem::write($targetFile, $yamlContent);
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Rector\Tests\Scan;
use Rector\HttpKernel\RectorKernel;
use Rector\Rector\ClassMethod\AddReturnTypeDeclarationRector;
use Rector\Scan\ScannedErrorToRectorResolver;
use Rector\TypeDeclaration\Rector\ClassMethod\AddParamTypeDeclarationRector;
use Symplify\PackageBuilder\Tests\AbstractKernelTestCase;
final class ScannedErrorToRectorResolverTest extends AbstractKernelTestCase
{
/**
* @var ScannedErrorToRectorResolver
*/
private $scannedErrorToRectorResolver;
protected function setUp(): void
{
$this->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);
}
}