From f309c755f857b8ea788f4e287668b27807ab1f06 Mon Sep 17 00:00:00 2001 From: Brown Date: Tue, 4 Jun 2019 16:36:32 -0400 Subject: [PATCH] Add ability to move classes --- src/Psalm/Codebase.php | 10 + src/Psalm/FileManipulation.php | 25 +- src/Psalm/Internal/Analyzer/ClassAnalyzer.php | 127 +++++- .../Analyzer/FunctionLikeAnalyzer.php | 55 ++- .../Internal/Analyzer/ProjectAnalyzer.php | 82 +++- .../Statements/Block/ForeachAnalyzer.php | 20 + .../Assignment/PropertyAssignmentAnalyzer.php | 78 ++-- .../Expression/AssignmentAnalyzer.php | 24 +- .../Expression/Call/NewAnalyzer.php | 18 +- .../Fetch/PropertyFetchAnalyzer.php | 62 +-- .../Statements/ExpressionAnalyzer.php | 10 + .../Internal/Analyzer/StatementsAnalyzer.php | 25 +- src/Psalm/Internal/Codebase/Analyzer.php | 5 +- src/Psalm/Internal/Codebase/ClassLikes.php | 195 +++++++++- .../Internal/Visitor/ReflectorVisitor.php | 9 + src/Psalm/Storage/ClassLikeStorage.php | 5 + src/Psalm/Type.php | 16 + src/Psalm/Type/Atomic.php | 142 +++++++ src/Psalm/Type/Union.php | 24 ++ tests/FileManipulation/ClassMoveTest.php | 363 ++++++++++++++++++ tests/FileManipulation/MethodMoveTest.php | 2 +- .../ReturnTypeManipulationTest.php | 4 +- 22 files changed, 1133 insertions(+), 168 deletions(-) create mode 100644 tests/FileManipulation/ClassMoveTest.php diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index c700b06db..2390ff62b 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -207,6 +207,11 @@ class Codebase */ public $class_constants_to_rename = []; + /** + * @var array + */ + public $classes_to_move = []; + /** * @var array */ @@ -222,6 +227,11 @@ class Codebase */ public $class_constant_transforms = []; + /** + * @var array + */ + public $class_transforms = []; + /** * @var bool */ diff --git a/src/Psalm/FileManipulation.php b/src/Psalm/FileManipulation.php index bf5ebb895..4a80f19f6 100644 --- a/src/Psalm/FileManipulation.php +++ b/src/Psalm/FileManipulation.php @@ -12,20 +12,43 @@ class FileManipulation /** @var string */ public $insertion_text; + /** @var bool */ + public $preserve_indentation; + /** * @param int $start * @param int $end * @param string $insertion_text */ - public function __construct(int $start, int $end, string $insertion_text) + public function __construct(int $start, int $end, string $insertion_text, bool $preserve_indentation = false) { $this->start = $start; $this->end = $end; $this->insertion_text = $insertion_text; + $this->preserve_indentation = $preserve_indentation; } public function getKey() : string { return sha1($this->start . ':' . $this->insertion_text); } + + public function transform(string $existing_contents) : string + { + if ($this->preserve_indentation) { + $newline_pos = strrpos($existing_contents, "\n", $this->start - strlen($existing_contents)); + + $newline_pos = $newline_pos !== false ? $newline_pos + 1 : 0; + + $indentation = substr($existing_contents, $newline_pos, $this->start - $newline_pos); + + if (trim($indentation) === '') { + $this->insertion_text = $this->insertion_text . $indentation; + } + } + + return substr($existing_contents, 0, $this->start) + . $this->insertion_text + . substr($existing_contents, $this->end); + } } diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index 120d2eef1..4f9542cec 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -131,6 +131,64 @@ class ClassAnalyzer extends ClassLikeAnalyzer $project_analyzer = $this->file_analyzer->project_analyzer; $codebase = $this->getCodebase(); + if ($codebase->alter_code && $class->name && $codebase->classes_to_move) { + if (isset($codebase->classes_to_move[strtolower($this->fq_class_name)])) { + $destination_class = $codebase->classes_to_move[strtolower($this->fq_class_name)]; + + $source_class_parts = explode('\\', $this->fq_class_name); + $destination_class_parts = explode('\\', $destination_class); + + array_pop($source_class_parts); + array_pop($destination_class_parts); + + $source_ns = implode('\\', $source_class_parts); + $destination_ns = implode('\\', $destination_class_parts); + + if (strtolower($source_ns) !== strtolower($destination_ns)) { + if ($storage->namespace_name_location) { + $bounds = $storage->namespace_name_location->getSelectionBounds(); + + $file_manipulations = [ + new \Psalm\FileManipulation( + $bounds[0], + $bounds[1], + $destination_ns + ) + ]; + + \Psalm\Internal\FileManipulation\FileManipulationBuffer::add( + $this->getFilePath(), + $file_manipulations + ); + } elseif (!$source_ns) { + $class_start_pos = (int) $class->getAttribute('startFilePos'); + + $file_manipulations = [ + new \Psalm\FileManipulation( + $class_start_pos, + $class_start_pos, + 'namespace ' . $destination_ns . ';' . "\n\n", + true + ) + ]; + + \Psalm\Internal\FileManipulation\FileManipulationBuffer::add( + $this->getFilePath(), + $file_manipulations + ); + } + } + } + + $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $this, + $class->name, + $this->fq_class_name, + null + ); + } + $classlike_storage_provider = $codebase->classlike_storage_provider; $parent_fq_class_name = $this->parent_fq_class_name; @@ -152,6 +210,16 @@ class ClassAnalyzer extends ClassLikeAnalyzer return false; } + if ($codebase->alter_code && $codebase->classes_to_move) { + $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $this, + $class->extends, + $parent_fq_class_name, + null + ); + } + try { $parent_class_storage = $classlike_storage_provider->get($parent_fq_class_name); @@ -279,6 +347,14 @@ class ClassAnalyzer extends ClassLikeAnalyzer ); } + $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $this, + $interface_name, + $fq_interface_name, + null + ); + try { $interface_storage = $classlike_storage_provider->get($fq_interface_name); } catch (\InvalidArgumentException $e) { @@ -681,22 +757,47 @@ class ClassAnalyzer extends ClassLikeAnalyzer break; } - $property_id = strtolower($this->fq_class_name) . '::$' . $prop->name; + if ($codebase->alter_code) { + $property_id = strtolower($this->fq_class_name) . '::$' . $prop->name; - foreach ($codebase->properties_to_rename as $original_property_id => $new_property_name) { - if ($property_id === $original_property_id) { - $file_manipulations = [ - new \Psalm\FileManipulation( - (int) $prop->name->getAttribute('startFilePos'), - (int) $prop->name->getAttribute('endFilePos') + 1, - '$' . $new_property_name - ) - ]; + $property_storage = $codebase->properties->getStorage($property_id); - \Psalm\Internal\FileManipulation\FileManipulationBuffer::add( - $this->getFilePath(), - $file_manipulations + if ($property_storage->type + && $property_storage->type_location + && $property_storage->type_location !== $property_storage->signature_type_location + ) { + $replace_type = ExpressionAnalyzer::fleshOutType( + $codebase, + $property_storage->type, + $this->getFQCLN(), + $this->getFQCLN(), + $this->getParentFQCLN() ); + + $codebase->classlikes->handleDocblockTypeInMigration( + $codebase, + $this, + $replace_type, + $property_storage->type_location, + null + ); + } + + foreach ($codebase->properties_to_rename as $original_property_id => $new_property_name) { + if ($property_id === $original_property_id) { + $file_manipulations = [ + new \Psalm\FileManipulation( + (int) $prop->name->getAttribute('startFilePos'), + (int) $prop->name->getAttribute('endFilePos') + 1, + '$' . $new_property_name + ) + ]; + + \Psalm\Internal\FileManipulation\FileManipulationBuffer::add( + $this->getFilePath(), + $file_manipulations + ); + } } } } diff --git a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php index ad85d95d0..01438bf66 100644 --- a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php @@ -361,12 +361,7 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements $check_stmts = true; - if ($codebase->methods_to_move - && $context->calling_method_id - && isset($codebase->methods_to_move[strtolower($context->calling_method_id)]) - ) { - $destination_method_id = $codebase->methods_to_move[strtolower($context->calling_method_id)]; - + if ($codebase->alter_code) { foreach ($this->function->params as $param) { $param_name_node = null; @@ -379,7 +374,7 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements } if ($param_name_node) { - $resolved_name = (string) $param_name_node->getAttribute('resolvedName'); + $resolved_name = ClassLikeAnalyzer::getFQCLNFromNameObject($param_name_node, $this->getAliases()); $parent_fqcln = $this->getParentFQCLN(); @@ -389,12 +384,12 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements $resolved_name = $parent_fqcln; } - $codebase->classlikes->airliftClassLikeReference( + $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $this, + $param_name_node, $resolved_name, - explode('::', $destination_method_id)[0], - $statements_analyzer->getFilePath(), - (int) $param_name_node->getAttribute('startFilePos'), - (int) $param_name_node->getAttribute('endFilePos') + 1 + $context->calling_method_id ); } } @@ -411,7 +406,7 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements } if ($return_name_node) { - $resolved_name = (string) $return_name_node->getAttribute('resolvedName'); + $resolved_name = ClassLikeAnalyzer::getFQCLNFromNameObject($return_name_node, $this->getAliases()); $parent_fqcln = $this->getParentFQCLN(); @@ -421,12 +416,12 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements $resolved_name = $parent_fqcln; } - $codebase->classlikes->airliftClassLikeReference( + $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $this, + $return_name_node, $resolved_name, - explode('::', $destination_method_id)[0], - $statements_analyzer->getFilePath(), - (int) $return_name_node->getAttribute('startFilePos'), - (int) $return_name_node->getAttribute('endFilePos') + 1 + $context->calling_method_id ); } } @@ -435,8 +430,6 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements && $storage->return_type_location && $storage->return_type_location !== $storage->signature_return_type_location ) { - $bounds = $storage->return_type_location->getSelectionBounds(); - $replace_type = ExpressionAnalyzer::fleshOutType( $codebase, $storage->return_type, @@ -445,12 +438,12 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements $this->getParentFQCLN() ); - $codebase->classlikes->airliftDocblockType( + $codebase->classlikes->handleDocblockTypeInMigration( + $codebase, + $this, $replace_type, - explode('::', $destination_method_id)[0], - $statements_analyzer->getFilePath(), - $bounds[0], - $bounds[1] + $storage->return_type_location, + $context->calling_method_id ); } @@ -459,8 +452,6 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements && $function_param->type_location && $function_param->type_location !== $function_param->signature_type_location ) { - $bounds = $function_param->type_location->getSelectionBounds(); - $replace_type = ExpressionAnalyzer::fleshOutType( $codebase, $function_param->type, @@ -469,12 +460,12 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements $this->getParentFQCLN() ); - $codebase->classlikes->airliftDocblockType( + $codebase->classlikes->handleDocblockTypeInMigration( + $codebase, + $this, $replace_type, - explode('::', $destination_method_id)[0], - $statements_analyzer->getFilePath(), - $bounds[0], - $bounds[1] + $function_param->type_location, + $context->calling_method_id ); } } diff --git a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php index 70a9cada2..aea6e9cad 100644 --- a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php @@ -539,16 +539,77 @@ class ProjectAnalyzer $source_parts = explode('::', $source); $destination_parts = explode('::', $destination); - if (count($source_parts) === 1 || count($destination_parts) === 1) { - throw new \Psalm\Exception\RefactorException('Cannot yet refactor classes'); - } - - if (!$this->codebase->classlikes->classExists($source_parts[0])) { + if (!$this->codebase->classlikes->hasFullyQualifiedClassName($source_parts[0])) { throw new \Psalm\Exception\RefactorException( 'Source class ' . $source_parts[0] . ' doesn’t exist' ); } + if (count($source_parts) === 1 && count($destination_parts) === 1) { + if ($this->codebase->classlikes->hasFullyQualifiedClassName($destination_parts[0])) { + throw new \Psalm\Exception\RefactorException( + 'Destination class ' . $destination_parts[0] . ' already exists' + ); + } + + $source_class_storage = $this->codebase->classlike_storage_provider->get($source_parts[0]); + + foreach ($source_class_storage->methods as $method_name => $method_storage) { + if ($method_storage->is_static) { + $old_method_id = strtolower($source_parts[0] . '::' . $method_name); + $new_method_id = $destination_parts[0] . '::' . $method_name; + + $this->codebase->call_transforms[$old_method_id . '\((.*\))'] = $new_method_id . '($1)'; + } + } + + foreach ($source_class_storage->properties as $property_name => $property_storage) { + if ($property_storage->is_static) { + $old_property_id = strtolower($source_parts[0]) . '::' . $property_name; + $new_property_id = $destination_parts[0] . '::' . $property_name; + + $this->codebase->property_transforms[$old_property_id] = $new_property_id; + } + } + + $all_class_consts = array_merge( + $source_class_storage->public_class_constants, + $source_class_storage->protected_class_constants, + $source_class_storage->private_class_constants + ); + + foreach ($all_class_consts as $const_name => $_) { + $old_const_id = strtolower($source_parts[0]) . '::' . $const_name; + $new_const_id = $destination_parts[0] . '::' . $const_name; + + $this->codebase->class_constant_transforms[$old_const_id] = $new_const_id; + } + + $destination_parts = explode('\\', $destination); + + array_pop($destination_parts); + $destination_ns = implode('\\', $destination_parts); + + $this->codebase->classes_to_move[strtolower($source)] = $destination; + + $destination_class_storage = $this->codebase->classlike_storage_provider->create($destination); + + $destination_class_storage->name = $destination; + + if ($source_class_storage->aliases) { + $destination_class_storage->aliases = clone $source_class_storage->aliases; + $destination_class_storage->aliases->namespace = $destination_ns; + } + + $destination_class_storage->location = $source_class_storage->location; + $destination_class_storage->stmt_location = $source_class_storage->stmt_location; + $destination_class_storage->populated = true; + + $this->codebase->class_transforms[strtolower($source)] = $destination; + + continue; + } + if ($this->codebase->methods->methodExists($source)) { if ($this->codebase->methods->methodExists($destination)) { throw new \Psalm\Exception\RefactorException( @@ -631,7 +692,7 @@ class ProjectAnalyzer ); if (isset($source_class_constants[$source_parts[1]])) { - if (!$this->codebase->classlikes->classExists($destination_parts[0])) { + if (!$this->codebase->classlikes->hasFullyQualifiedClassName($destination_parts[0])) { throw new \Psalm\Exception\RefactorException( 'Destination class ' . $destination_parts[0] . ' doesn’t exist' ); @@ -682,9 +743,11 @@ class ProjectAnalyzer $this->progress ); - $this->codebase->classlikes->moveConstants( + $this->codebase->classlikes->moveClassConstants( $this->progress ); + + $this->codebase->classlikes->moveClasses(); } public function migrateCode() : void @@ -723,10 +786,7 @@ class ProjectAnalyzer $existing_contents = $this->codebase->file_provider->getContents($file_path); foreach ($file_manipulations as $manipulation) { - $existing_contents - = substr($existing_contents, 0, $manipulation->start) - . $manipulation->insertion_text - . substr($existing_contents, $manipulation->end); + $existing_contents = $manipulation->transform($existing_contents); } $this->codebase->file_provider->setContents($file_path, $existing_contents); diff --git a/src/Psalm/Internal/Analyzer/Statements/Block/ForeachAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Block/ForeachAnalyzer.php index 61edec4e8..4b661b5ea 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Block/ForeachAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Block/ForeachAnalyzer.php @@ -110,6 +110,26 @@ class ForeachAnalyzer $statements_analyzer->getParentFQCLN() ); + if ($var_comment->type_start + && $var_comment->type_end + && $var_comment->line_number + ) { + $type_location = new CodeLocation\DocblockTypeLocation( + $statements_analyzer, + $var_comment->type_start, + $var_comment->type_end, + $var_comment->line_number, + ); + + $codebase->classlikes->handleDocblockTypeInMigration( + $codebase, + $statements_analyzer, + $comment_type, + $type_location, + $context->calling_method_id + ); + } + if (isset($context->vars_in_scope[$var_comment->var_id]) || $statements_analyzer->isSuperGlobal($var_comment->var_id) ) { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/PropertyAssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/PropertyAssignmentAnalyzer.php index 68fe8d72d..280792629 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/PropertyAssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/PropertyAssignmentAnalyzer.php @@ -901,22 +901,6 @@ class PropertyAssignmentAnalyzer $codebase = $statements_analyzer->getCodebase(); - if ($stmt->class instanceof PhpParser\Node\Name - && $codebase->methods_to_move - && $context->calling_method_id - && isset($codebase->methods_to_move[strtolower($context->calling_method_id)]) - ) { - $destination_method_id = $codebase->methods_to_move[strtolower($context->calling_method_id)]; - - $codebase->classlikes->airliftClassLikeReference( - $fq_class_name, - explode('::', $destination_method_id)[0], - $statements_analyzer->getFilePath(), - (int) $stmt->class->getAttribute('startFilePos'), - (int) $stmt->class->getAttribute('endFilePos') + 1 - ); - } - $prop_name = $stmt->name; if (!$prop_name instanceof PhpParser\Node\Identifier) { @@ -964,35 +948,45 @@ class PropertyAssignmentAnalyzer $declaring_property_id = strtolower((string) $declaring_property_class) . '::$' . $prop_name; - foreach ($codebase->property_transforms as $original_pattern => $transformation) { - if ($declaring_property_id === $original_pattern - && $stmt->class instanceof PhpParser\Node\Name - ) { - list($old_declaring_fq_class_name) = explode('::$', $declaring_property_id); - list($new_fq_class_name, $new_property_name) = explode('::$', $transformation); + if ($codebase->alter_code && $stmt->class instanceof PhpParser\Node\Name) { + $moved_class = $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $statements_analyzer, + $stmt->class, + $fq_class_name, + $context->calling_method_id + ); - $file_manipulations = []; + if (!$moved_class) { + foreach ($codebase->property_transforms as $original_pattern => $transformation) { + if ($declaring_property_id === $original_pattern) { + list($old_declaring_fq_class_name) = explode('::$', $declaring_property_id); + list($new_fq_class_name, $new_property_name) = explode('::$', $transformation); - if (strtolower($new_fq_class_name) !== strtolower($old_declaring_fq_class_name)) { - $file_manipulations[] = new \Psalm\FileManipulation( - (int) $stmt->class->getAttribute('startFilePos'), - (int) $stmt->class->getAttribute('endFilePos') + 1, - Type::getStringFromFQCLN( - $new_fq_class_name, - $statements_analyzer->getNamespace(), - $statements_analyzer->getAliasedClassesFlipped(), - null - ) - ); + $file_manipulations = []; + + if (strtolower($new_fq_class_name) !== strtolower($old_declaring_fq_class_name)) { + $file_manipulations[] = new \Psalm\FileManipulation( + (int) $stmt->class->getAttribute('startFilePos'), + (int) $stmt->class->getAttribute('endFilePos') + 1, + Type::getStringFromFQCLN( + $new_fq_class_name, + $statements_analyzer->getNamespace(), + $statements_analyzer->getAliasedClassesFlipped(), + null + ) + ); + } + + $file_manipulations[] = new \Psalm\FileManipulation( + (int) $stmt->name->getAttribute('startFilePos'), + (int) $stmt->name->getAttribute('endFilePos') + 1, + '$' . $new_property_name + ); + + FileManipulationBuffer::add($statements_analyzer->getFilePath(), $file_manipulations); + } } - - $file_manipulations[] = new \Psalm\FileManipulation( - (int) $stmt->name->getAttribute('startFilePos'), - (int) $stmt->name->getAttribute('endFilePos') + 1, - '$' . $new_property_name - ); - - FileManipulationBuffer::add($statements_analyzer->getFilePath(), $file_manipulations); } } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php index ab42eccb3..696b47c5b 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php @@ -120,21 +120,23 @@ class AssignmentAnalyzer $statements_analyzer->getSuppressedIssues() ); - if ($codebase->methods_to_move - && $context->calling_method_id - && isset($codebase->methods_to_move[strtolower($context->calling_method_id)]) - && $var_comment->type_start + if ($var_comment->type_start && $var_comment->type_end && $var_comment->line_number ) { - $destination_method_id = $codebase->methods_to_move[strtolower($context->calling_method_id)]; - - $codebase->classlikes->airliftDocblockType( - $var_comment_type, - explode('::', $destination_method_id)[0], - $statements_analyzer->getFilePath(), + $type_location = new CodeLocation\DocblockTypeLocation( + $statements_analyzer, $var_comment->type_start, - $var_comment->type_end + $var_comment->type_end, + $var_comment->line_number, + ); + + $codebase->classlikes->handleDocblockTypeInMigration( + $codebase, + $statements_analyzer, + $var_comment_type, + $type_location, + $context->calling_method_id ); } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php index 568d933fc..0a8c64c44 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php @@ -246,19 +246,13 @@ class NewAnalyzer extends \Psalm\Internal\Analyzer\Statements\Expression\CallAna } if ($fq_class_name) { - if ($stmt->class instanceof PhpParser\Node\Name - && $codebase->methods_to_move - && $context->calling_method_id - && isset($codebase->methods_to_move[strtolower($context->calling_method_id)]) - ) { - $destination_method_id = $codebase->methods_to_move[strtolower($context->calling_method_id)]; - - $codebase->classlikes->airliftClassLikeReference( + if ($codebase->alter_code) { + $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $statements_analyzer, + $stmt->class, $fq_class_name, - explode('::', $destination_method_id)[0], - $statements_analyzer->getFilePath(), - (int) $stmt->class->getAttribute('startFilePos'), - (int) $stmt->class->getAttribute('endFilePos') + 1 + $context->calling_method_id ); } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/PropertyFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/PropertyFetchAnalyzer.php index a258a280a..bddc09385 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/PropertyFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/PropertyFetchAnalyzer.php @@ -1030,35 +1030,45 @@ class PropertyFetchAnalyzer $declaring_property_id = strtolower((string) $declaring_property_class) . '::$' . $prop_name; - foreach ($codebase->property_transforms as $original_pattern => $transformation) { - if ($declaring_property_id === $original_pattern - && $stmt->class instanceof PhpParser\Node\Name - ) { - list($old_declaring_fq_class_name) = explode('::$', $declaring_property_id); - list($new_fq_class_name, $new_property_name) = explode('::$', $transformation); + if ($codebase->alter_code && $stmt->class instanceof PhpParser\Node\Name) { + $moved_class = $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $statements_analyzer, + $stmt->class, + $fq_class_name, + $context->calling_method_id + ); - $file_manipulations = []; + if (!$moved_class) { + foreach ($codebase->property_transforms as $original_pattern => $transformation) { + if ($declaring_property_id === $original_pattern) { + list($old_declaring_fq_class_name) = explode('::$', $declaring_property_id); + list($new_fq_class_name, $new_property_name) = explode('::$', $transformation); - if (strtolower($new_fq_class_name) !== strtolower($old_declaring_fq_class_name)) { - $file_manipulations[] = new \Psalm\FileManipulation( - (int) $stmt->class->getAttribute('startFilePos'), - (int) $stmt->class->getAttribute('endFilePos') + 1, - Type::getStringFromFQCLN( - $new_fq_class_name, - $statements_analyzer->getNamespace(), - $statements_analyzer->getAliasedClassesFlipped(), - null - ) - ); + $file_manipulations = []; + + if (strtolower($new_fq_class_name) !== strtolower($old_declaring_fq_class_name)) { + $file_manipulations[] = new \Psalm\FileManipulation( + (int) $stmt->class->getAttribute('startFilePos'), + (int) $stmt->class->getAttribute('endFilePos') + 1, + Type::getStringFromFQCLN( + $new_fq_class_name, + $statements_analyzer->getNamespace(), + $statements_analyzer->getAliasedClassesFlipped(), + null + ) + ); + } + + $file_manipulations[] = new \Psalm\FileManipulation( + (int) $stmt->name->getAttribute('startFilePos'), + (int) $stmt->name->getAttribute('endFilePos') + 1, + '$' . $new_property_name + ); + + FileManipulationBuffer::add($statements_analyzer->getFilePath(), $file_manipulations); + } } - - $file_manipulations[] = new \Psalm\FileManipulation( - (int) $stmt->name->getAttribute('startFilePos'), - (int) $stmt->name->getAttribute('endFilePos') + 1, - '$' . $new_property_name - ); - - FileManipulationBuffer::add($statements_analyzer->getFilePath(), $file_manipulations); } } diff --git a/src/Psalm/Internal/Analyzer/Statements/ExpressionAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/ExpressionAnalyzer.php index 56fc683df..f22da1d50 100644 --- a/src/Psalm/Internal/Analyzer/Statements/ExpressionAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/ExpressionAnalyzer.php @@ -579,6 +579,16 @@ class ExpressionAnalyzer $fq_class_name ); } + + if ($codebase->alter_code) { + $codebase->classlikes->handleClassLikeReferenceInMigration( + $codebase, + $statements_analyzer, + $stmt->class, + $fq_class_name, + $context->calling_method_id + ); + } } } diff --git a/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php b/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php index 48da3343f..3778d8f6c 100644 --- a/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php @@ -950,22 +950,23 @@ class StatementsAnalyzer extends SourceAnalyzer implements StatementsSource $this->getSuppressedIssues() ); - if ($codebase->methods_to_move - && $context->calling_method_id - && isset($codebase->methods_to_move[strtolower($context->calling_method_id)]) - && $var_comment->type_start + if ($var_comment->type_start && $var_comment->type_end && $var_comment->line_number ) { - $destination_method_id - = $codebase->methods_to_move[strtolower($context->calling_method_id)]; - - $codebase->classlikes->airliftDocblockType( - $var_comment_type, - explode('::', $destination_method_id)[0], - $this->getFilePath(), + $type_location = new CodeLocation\DocblockTypeLocation( + $this, $var_comment->type_start, - $var_comment->type_end + $var_comment->type_end, + $var_comment->line_number + ); + + $codebase->classlikes->handleDocblockTypeInMigration( + $codebase, + $this, + $var_comment_type, + $type_location, + $context->calling_method_id ); } diff --git a/src/Psalm/Internal/Codebase/Analyzer.php b/src/Psalm/Internal/Codebase/Analyzer.php index 9960fc2a1..cd6658209 100644 --- a/src/Psalm/Internal/Codebase/Analyzer.php +++ b/src/Psalm/Internal/Codebase/Analyzer.php @@ -1156,10 +1156,7 @@ class Analyzer $existing_contents = $this->file_provider->getContents($file_path); foreach ($file_manipulations as $manipulation) { - $existing_contents - = substr($existing_contents, 0, $manipulation->start) - . $manipulation->insertion_text - . substr($existing_contents, $manipulation->end); + $existing_contents = $manipulation->transform($existing_contents); } if ($dry_run) { diff --git a/src/Psalm/Internal/Codebase/ClassLikes.php b/src/Psalm/Internal/Codebase/ClassLikes.php index 13640c8e7..148c53560 100644 --- a/src/Psalm/Internal/Codebase/ClassLikes.php +++ b/src/Psalm/Internal/Codebase/ClassLikes.php @@ -889,7 +889,7 @@ class ClassLikes /** * @return void */ - public function moveConstants(Progress $progress = null) + public function moveClassConstants(Progress $progress = null) { if ($progress === null) { $progress = new VoidProgress(); @@ -959,6 +959,199 @@ class ClassLikes FileManipulationBuffer::addCodeMigrations($code_migrations); } + /** + * @return void + */ + public function moveClasses() + { + } + + public function handleClassLikeReferenceInMigration( + \Psalm\Codebase $codebase, + \Psalm\StatementsSource $source, + PhpParser\Node $class_name_node, + string $fq_class_name, + ?string $calling_method_id + ) : bool { + + $calling_fq_class_name = $source->getFQCLN(); + + // if we're inside a moved class static method + if ($codebase->methods_to_move + && $calling_fq_class_name + && $calling_method_id + && isset($codebase->methods_to_move[strtolower($calling_method_id)]) + ) { + $destination_class = explode('::', $codebase->methods_to_move[strtolower($calling_method_id)])[0]; + + $intended_fq_class_name = strtolower($calling_fq_class_name) === strtolower($fq_class_name) + && isset($codebase->classes_to_move[strtolower($calling_fq_class_name)]) + ? $destination_class + : $fq_class_name; + + $this->airliftClassLikeReference( + $intended_fq_class_name, + $destination_class, + $source->getFilePath(), + (int) $class_name_node->getAttribute('startFilePos'), + (int) $class_name_node->getAttribute('endFilePos') + 1 + ); + + return true; + } + + // if we're inside a moved class (could be a method, could be a property/class const default) + if ($codebase->classes_to_move + && $calling_fq_class_name + && isset($codebase->classes_to_move[strtolower($calling_fq_class_name)]) + ) { + $destination_class = $codebase->classes_to_move[strtolower($calling_fq_class_name)]; + + if ($class_name_node instanceof PhpParser\Node\Identifier) { + $destination_parts = explode('\\', $destination_class); + + $destination_class_name = array_pop($destination_parts); + $file_manipulations = []; + + $file_manipulations[] = new \Psalm\FileManipulation( + (int) $class_name_node->getAttribute('startFilePos'), + (int) $class_name_node->getAttribute('endFilePos') + 1, + $destination_class_name + ); + + FileManipulationBuffer::add($source->getFilePath(), $file_manipulations); + } else { + $this->airliftClassLikeReference( + strtolower($calling_fq_class_name) === strtolower($fq_class_name) + ? $destination_class + : $fq_class_name, + $destination_class, + $source->getFilePath(), + (int) $class_name_node->getAttribute('startFilePos'), + (int) $class_name_node->getAttribute('endFilePos') + 1 + ); + } + + return true; + } + + // if we're outside a moved class, but we're changing all references to a class + foreach ($codebase->class_transforms as $old_fq_class_name => $new_fq_class_name) { + if (strtolower($fq_class_name) === $old_fq_class_name) { + $file_manipulations = []; + + $file_manipulations[] = new \Psalm\FileManipulation( + (int) $class_name_node->getAttribute('startFilePos'), + (int) $class_name_node->getAttribute('endFilePos') + 1, + Type::getStringFromFQCLN( + $new_fq_class_name, + $source->getNamespace(), + $source->getAliasedClassesFlipped(), + $calling_fq_class_name + ) + ); + + FileManipulationBuffer::add($source->getFilePath(), $file_manipulations); + + return true; + } + } + + return false; + } + + public function handleDocblockTypeInMigration( + \Psalm\Codebase $codebase, + \Psalm\StatementsSource $source, + Type\Union $type, + CodeLocation $type_location, + ?string $calling_method_id + ) : void { + + $calling_fq_class_name = $source->getFQCLN(); + + $moved_type = false; + + // if we're inside a moved class static method + if ($codebase->methods_to_move + && $calling_fq_class_name + && $calling_method_id + && isset($codebase->methods_to_move[strtolower($calling_method_id)]) + ) { + $bounds = $type_location->getSelectionBounds(); + + $destination_class = explode('::', $codebase->methods_to_move[strtolower($calling_method_id)])[0]; + + $this->airliftDocblockType( + $type, + $destination_class, + $source->getFilePath(), + $bounds[0], + $bounds[1] + ); + + $moved_type = true; + } + + // if we're inside a moved class (could be a method, could be a property/class const default) + if (!$moved_type + && $codebase->classes_to_move + && $calling_fq_class_name + && isset($codebase->classes_to_move[strtolower($calling_fq_class_name)]) + ) { + $bounds = $type_location->getSelectionBounds(); + + $destination_class = $codebase->classes_to_move[strtolower($calling_fq_class_name)]; + + if ($type->containsClassLike(strtolower($calling_fq_class_name))) { + $type = clone $type; + + $type->replaceClassLike(strtolower($calling_fq_class_name), $destination_class); + } + + $this->airliftDocblockType( + $type, + $destination_class, + $source->getFilePath(), + $bounds[0], + $bounds[1] + ); + + $moved_type = true; + } + + // if we're outside a moved class, but we're changing all references to a class + if (!$moved_type) { + foreach ($codebase->class_transforms as $old_fq_class_name => $new_fq_class_name) { + if ($type->containsClassLike($old_fq_class_name)) { + $type = clone $type; + + $type->replaceClassLike($old_fq_class_name, $new_fq_class_name); + + $bounds = $type_location->getSelectionBounds(); + + $file_manipulations = []; + + $file_manipulations[] = new \Psalm\FileManipulation( + $bounds[0], + $bounds[1], + $type->toNamespacedString( + $source->getNamespace(), + $source->getAliasedClassesFlipped(), + null, + false + ) + ); + + FileManipulationBuffer::add( + $source->getFilePath(), + $file_manipulations + ); + } + } + } + } + public function airliftClassLikeReference( string $fq_class_name, string $destination_fq_class_name, diff --git a/src/Psalm/Internal/Visitor/ReflectorVisitor.php b/src/Psalm/Internal/Visitor/ReflectorVisitor.php index bef2ba895..b10bb864e 100644 --- a/src/Psalm/Internal/Visitor/ReflectorVisitor.php +++ b/src/Psalm/Internal/Visitor/ReflectorVisitor.php @@ -93,6 +93,9 @@ class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements PhpParse /** @var int */ private $php_minor_version; + /** @var PhpParser\Node\Name|null */ + private $namespace_name; + /** * @var array> */ @@ -161,6 +164,9 @@ class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements PhpParse if ($node instanceof PhpParser\Node\Stmt\Namespace_) { $this->file_aliases = $this->aliases; + + $this->namespace_name = $node->name; + $this->aliases = new Aliases( $node->name ? implode('\\', $node->name->parts) : '', $this->aliases->uses, @@ -818,6 +824,9 @@ class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements PhpParse $storage->stmt_location = $class_location; $storage->location = $name_location; + if ($this->namespace_name) { + $storage->namespace_name_location = new CodeLocation($this->file_scanner, $this->namespace_name); + } $storage->user_defined = !$this->codebase->register_stub_files; $storage->stubbed = $this->codebase->register_stub_files; $storage->aliases = $this->aliases; diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php index b4841fe4a..389c4ea0b 100644 --- a/src/Psalm/Storage/ClassLikeStorage.php +++ b/src/Psalm/Storage/ClassLikeStorage.php @@ -176,6 +176,11 @@ class ClassLikeStorage */ public $stmt_location; + /** + * @var CodeLocation|null + */ + public $namespace_name_location; + /** * @var bool */ diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index e674c8588..316855811 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -1039,6 +1039,22 @@ abstract class Type return $aliased_classes[strtolower($value)]; } + if (strpos($value, '\\')) { + $parts = explode('\\', $value); + + $suffix = array_pop($parts); + + while ($parts) { + $left = implode('\\', $parts); + + if (isset($aliased_classes[strtolower($left)])) { + return $aliased_classes[strtolower($left)] . '\\' . $suffix; + } + + $suffix = array_pop($parts) . '\\' . $suffix; + } + } + return '\\' . $value; } diff --git a/src/Psalm/Type/Atomic.php b/src/Psalm/Type/Atomic.php index 56e9ae6c9..79ee48844 100644 --- a/src/Psalm/Type/Atomic.php +++ b/src/Psalm/Type/Atomic.php @@ -709,6 +709,148 @@ abstract class Atomic } } + public function containsClassLike(string $fq_classlike_name) : bool + { + if ($this instanceof TNamedObject) { + if (strtolower($this->value) === $fq_classlike_name) { + return true; + } + } + + if ($this instanceof TNamedObject + || $this instanceof TIterable + || $this instanceof TTemplateParam + ) { + if ($this->extra_types) { + foreach ($this->extra_types as $extra_type) { + if ($extra_type->containsClassLike($fq_classlike_name)) { + return true; + } + } + } + } + + if ($this instanceof TScalarClassConstant) { + if (strtolower($this->fq_classlike_name) === $fq_classlike_name) { + return true; + } + } + + if ($this instanceof TClassString && $this->as !== 'object') { + if (strtolower($this->as) === $fq_classlike_name) { + return true; + } + } + + if ($this instanceof TTemplateParam) { + if ($this->as->containsClassLike($fq_classlike_name)) { + return true; + } + } + + if ($this instanceof TLiteralClassString) { + if (strtolower($this->value) === $fq_classlike_name) { + return true; + } + } + + if ($this instanceof Type\Atomic\TArray + || $this instanceof Type\Atomic\TGenericObject + || $this instanceof Type\Atomic\TIterable + ) { + foreach ($this->type_params as $type_param) { + if ($type_param->containsClassLike($fq_classlike_name)) { + return true; + } + } + } + + if ($this instanceof Type\Atomic\TFn + || $this instanceof Type\Atomic\TCallable + ) { + if ($this->params) { + foreach ($this->params as $param) { + if ($param->type && $param->type->containsClassLike($fq_classlike_name)) { + return true; + } + } + } + + if ($this->return_type && $this->return_type->containsClassLike($fq_classlike_name)) { + return true; + } + } + + return false; + } + + public function replaceClassLike(string $old, string $new) : void + { + if ($this instanceof TNamedObject) { + if (strtolower($this->value) === $old) { + $this->value = $new; + } + } + + if ($this instanceof TNamedObject + || $this instanceof TIterable + || $this instanceof TTemplateParam + ) { + if ($this->extra_types) { + foreach ($this->extra_types as $extra_type) { + $extra_type->replaceClassLike($old, $new); + } + } + } + + if ($this instanceof TScalarClassConstant) { + if (strtolower($old) === $new) { + $this->fq_classlike_name = $new; + } + } + + if ($this instanceof TClassString && $this->as !== 'object') { + if (strtolower($this->as) === $old) { + $this->as = $new; + } + } + + if ($this instanceof TTemplateParam) { + $this->as->replaceClassLike($old, $new); + } + + if ($this instanceof TLiteralClassString) { + if (strtolower($this->value) === $old) { + $this->value = $new; + } + } + + if ($this instanceof Type\Atomic\TArray + || $this instanceof Type\Atomic\TGenericObject + || $this instanceof Type\Atomic\TIterable + ) { + foreach ($this->type_params as $type_param) { + $type_param->replaceClassLike($old, $new); + } + } + + if ($this instanceof Type\Atomic\TFn + || $this instanceof Type\Atomic\TCallable + ) { + if ($this->params) { + foreach ($this->params as $param) { + if ($param->type) { + $param->type->replaceClassLike($old, $new); + } + } + } + + if ($this->return_type) { + $this->return_type->replaceClassLike($old, $new); + } + } + } + /** * @param Atomic $other * diff --git a/src/Psalm/Type/Union.php b/src/Psalm/Type/Union.php index 1e4f35408..ace0cd2f6 100644 --- a/src/Psalm/Type/Union.php +++ b/src/Psalm/Type/Union.php @@ -1674,6 +1674,30 @@ class Union } } + public function containsClassLike(string $fq_class_like_name) : bool + { + foreach ($this->types as $atomic_type) { + if ($atomic_type->containsClassLike($fq_class_like_name)) { + return true; + } + } + + return false; + } + + public function replaceClassLike(string $old, string $new) : void + { + foreach ($this->types as $key => $atomic_type) { + $atomic_type->replaceClassLike($old, $new); + + unset($this->types[$key]); + + $this->types[$atomic_type->getKey()] = $atomic_type; + } + + $this->id = null; + } + /** * @return bool */ diff --git a/tests/FileManipulation/ClassMoveTest.php b/tests/FileManipulation/ClassMoveTest.php new file mode 100644 index 000000000..b5ccb9176 --- /dev/null +++ b/tests/FileManipulation/ClassMoveTest.php @@ -0,0 +1,363 @@ +file_provider = new Provider\FakeFileProvider(); + } + + /** + * @dataProvider providerValidCodeParse + * + * @param string $input_code + * @param string $output_code + * @param array $constants_to_move + * @param array $call_transforms + * + * @return void + */ + public function testValidCode( + string $input_code, + string $output_code, + array $constants_to_move + ) { + $test_name = $this->getTestName(); + if (strpos($test_name, 'SKIPPED-') !== false) { + $this->markTestSkipped('Skipped due to a bug.'); + } + + $config = new TestConfig(); + + $this->project_analyzer = new \Psalm\Internal\Analyzer\ProjectAnalyzer( + $config, + new \Psalm\Internal\Provider\Providers( + $this->file_provider, + new Provider\FakeParserCacheProvider() + ) + ); + + $context = new Context(); + + $file_path = self::$src_dir_path . 'somefile.php'; + + $this->addFile( + $file_path, + $input_code + ); + + $codebase = $this->project_analyzer->getCodebase(); + + $this->project_analyzer->refactorCodeAfterCompletion($constants_to_move); + + $this->analyzeFile($file_path, $context); + + $this->project_analyzer->prepareMigration(); + + $codebase->analyzer->updateFile($file_path, false); + + $this->project_analyzer->migrateCode(); + + $this->assertSame($output_code, $codebase->getFileContents($file_path)); + } + + /** + * @return array}> + */ + public function providerValidCodeParse() + { + return [ + 'renameEmptyClass' => [ + ' 'Ns\B', + ] + ], + 'renameClassWithInstanceMethod' => [ + 'foo($a, $a); + }', + 'foo($a, $a); + }', + [ + 'Ns\A' => 'Ns\B', + ] + ], + 'renameClassWithStaticMethod' => [ + ' 'Ns\B', + ] + ], + 'renameClassWithInstanceProperty' => [ + ' 'Ns\B', + ] + ], + 'renameClassWithStaticProperty' => [ + ' 'Ns\B', + ] + ], + 'moveClassIntoNamespace' => [ + ' $a + */ + public function foo(ArrayObject $a) : Exception { + foreach ($a as $b) { + $b->bar(); + } + + return new Exception("bad"); + } + + public function bar() : void {} + }', + ' $a + */ + public function foo(\ArrayObject $a) : \Exception { + foreach ($a as $b) { + $b->bar(); + } + + return new \Exception("bad"); + } + + public function bar() : void {} + }', + [ + 'A' => 'Foo\Bar\Baz\B', + ] + ], + 'moveClassDeeperIntoNamespace' => [ + ' $a + */ + public function foo(ArrayObject $a) : Exception { + foreach ($a as $b) { + $b->bar(); + } + + return new Exception("bad"); + } + + public function bar() : void {} + }', + ' $a + */ + public function foo(ArrayObject $a) : Exception { + foreach ($a as $b) { + $b->bar(); + } + + return new Exception("bad"); + } + + public function bar() : void {} + }', + [ + 'Foo\A' => 'Foo\Bar\Baz\B', + ] + ], + ]; + } +} diff --git a/tests/FileManipulation/MethodMoveTest.php b/tests/FileManipulation/MethodMoveTest.php index 1f5741b07..6355477c0 100644 --- a/tests/FileManipulation/MethodMoveTest.php +++ b/tests/FileManipulation/MethodMoveTest.php @@ -6,7 +6,7 @@ use Psalm\Internal\Analyzer\FileAnalyzer; use Psalm\Tests\Internal\Provider; use Psalm\Tests\TestConfig; -class MoveMethodTest extends \Psalm\Tests\TestCase +class MethodMoveTest extends \Psalm\Tests\TestCase { /** @var \Psalm\Internal\Analyzer\ProjectAnalyzer */ protected $project_analyzer; diff --git a/tests/FileManipulation/ReturnTypeManipulationTest.php b/tests/FileManipulation/ReturnTypeManipulationTest.php index 709e830b0..45c3b119a 100644 --- a/tests/FileManipulation/ReturnTypeManipulationTest.php +++ b/tests/FileManipulation/ReturnTypeManipulationTest.php @@ -888,9 +888,9 @@ class ReturnTypeManipulationTest extends FileManipulationTest class D { /** - * @return \A\B\C[] + * @return B\C[] * - * @psalm-return array{0:\A\B\C} + * @psalm-return array{0:B\C} */ public function getArrayOfC(): array { return [new \A\B\C];