blob: 61cf3f9db8411b8ab49334950dff5d16e4f3f48c [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:analysis_server/src/services/correction/assist.dart';
import 'package:analysis_server/src/services/correction/dart/abstract_producer.dart';
import 'package:analysis_server/src/services/correction/fix.dart';
import 'package:analysis_server/src/services/correction/util.dart';
import 'package:analysis_server/src/utilities/extensions/range_factory.dart';
import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/constant/value.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:analyzer_plugin/utilities/assist/assist.dart';
import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart';
import 'package:analyzer_plugin/utilities/change_builder/change_builder_dart.dart';
import 'package:analyzer_plugin/utilities/fixes/fixes.dart';
import 'package:analyzer_plugin/utilities/range_factory.dart';
import 'package:collection/collection.dart';
class ConvertClassToEnum extends CorrectionProducer {
@override
AssistKind get assistKind => DartAssistKind.CONVERT_CLASS_TO_ENUM;
@override
bool get canBeAppliedInBulk => true;
@override
bool get canBeAppliedToFile => true;
@override
FixKind get fixKind => DartFixKind.CONVERT_CLASS_TO_ENUM;
@override
FixKind get multiFixKind => DartFixKind.CONVERT_CLASS_TO_ENUM_MULTI;
@override
Future<void> compute(ChangeBuilder builder) async {
if (!libraryElement.featureSet.isEnabled(Feature.enhanced_enums)) {
// If the library doesn't support enhanced_enums then the class can't be
// converted.
return;
}
if (libraryElement.units.length > 1) {
// If the library has any part files, then the class can't be converted
// because we don't currently have a performant way to access the ASTs for
// the parts to check for invocations of the constructors or subclasses of
// the class.
return;
}
var node = this.node;
if (node is! SimpleIdentifier) {
return;
}
var parent = node.parent;
if (parent is ClassDeclaration && parent.name == node) {
var description = _EnumDescription.fromClass(parent);
if (description != null) {
await builder.addDartFileEdit(file, (builder) {
description.applyChanges(builder, utils);
});
}
}
}
/// Return an instance of this class. Used as a tear-off in `AssistProcessor`.
static ConvertClassToEnum newInstance() => ConvertClassToEnum();
}
/// A superclass for the [_EnumVisitor] and [_NonEnumVisitor].
class _BaseVisitor extends RecursiveAstVisitor<void> {
/// The element representing the enum declaration that's being visited.
final ClassElement classElement;
_BaseVisitor(this.classElement);
/// Return `true` if the given [node] is an invocation of a generative
/// constructor from the class being converted.
bool invokesGenerativeConstructor(InstanceCreationExpression node) {
var constructorElement = node.constructorName.staticElement;
return constructorElement != null &&
!constructorElement.isFactory &&
constructorElement.enclosingElement == classElement;
}
}
/// An exception thrown by the visitors if a condition is found that prevents
/// the class from being converted.
class _CannotConvertException implements Exception {
final String message;
_CannotConvertException(this.message);
}
/// A representation of a static field in the class being converted that will be
/// replaced by an enum constant.
class _ConstantField extends _Field {
/// The element representing the constructor used to initialize the field.
ConstructorElement constructorElement;
/// The invocation of the constructor.
final InstanceCreationExpression instanceCreation;
/// The value of the index field.
final int indexValue;
_ConstantField(
FieldElement element,
VariableDeclaration declaration,
VariableDeclarationList declarationList,
FieldDeclaration fieldDeclaration,
this.instanceCreation,
this.constructorElement,
this.indexValue)
: super(element, declaration, declarationList, fieldDeclaration);
}
/// Information about a single constructor in the class being converted.
class _Constructor {
/// The declaration of the constructor.
final ConstructorDeclaration declaration;
/// The element representing the constructor.
final ConstructorElement element;
_Constructor(this.declaration, this.element);
}
/// Information about the constructors in the class being converted.
class _Constructors {
/// A map from elements to constructors.
final Map<ConstructorElement, _Constructor> byElement = {};
_Constructors();
/// Return the constructors in this collection.
Iterable<_Constructor> get constructors => byElement.values;
/// Add the given [constructor] to this collection.
void add(_Constructor constructor) {
byElement[constructor.element] = constructor;
}
/// Return the constructor with the given [element].
_Constructor? forElement(ConstructorElement element) {
return byElement[element];
}
}
/// A description of how to convert the class to an enum.
class _EnumDescription {
/// The class declaration being converted.
final ClassDeclaration classDeclaration;
/// A map from constructor declarations to information about the parameter
/// corresponding to the index field. The map is `null` if there is no index
/// field.
final Map<_Constructor, _Parameter>? constructorMap;
/// A list of the declarations to be converted into enum constants.
final _Fields fields;
/// A list of the indexes of members that need to be deleted.
final List<int> membersToDelete;
_EnumDescription({
required this.classDeclaration,
required this.constructorMap,
required this.fields,
required this.membersToDelete,
});
/// Return the offset immediately following the opening brace for the class
/// body.
int get bodyOffset => classDeclaration.leftBracket.end;
/// Use the [builder] and correction [utils] to apply the change necessary to
/// convert the class to an enum.
void applyChanges(DartFileEditBuilder builder, CorrectionUtils utils) {
// Replace the keyword.
builder.addSimpleReplacement(
range.token(classDeclaration.classKeyword), 'enum');
// Remove the extends clause if there is one.
final extendsClause = classDeclaration.extendsClause;
if (extendsClause != null) {
var followingToken = extendsClause.endToken.next!;
builder.addDeletion(range.startStart(extendsClause, followingToken));
}
// Compute the declarations of the enum constants and delete the fields
// being converted.
var members = classDeclaration.members;
var indent = utils.getIndent(1);
var eol = utils.endOfLine;
var constantsBuffer = StringBuffer();
var fieldsToConvert = fields.fieldsToConvert;
fieldsToConvert
.sort((first, second) => first.indexValue.compareTo(second.indexValue));
for (var field in fieldsToConvert) {
// Compute the declaration of the corresponding enum constant.
if (constantsBuffer.isNotEmpty) {
constantsBuffer.write(',$eol$indent');
}
constantsBuffer.write(field.name);
var invocation = field.instanceCreation;
var constructorNameNode = invocation.constructorName;
var invokedConstructorElement = field.constructorElement;
var invokedConstructor = constructorMap?.keys.firstWhere(
(constructor) => constructor.element == invokedConstructorElement);
var parameterData = constructorMap?[invokedConstructor];
var typeArguments = constructorNameNode.type.typeArguments;
if (typeArguments != null) {
constantsBuffer.write(utils.getNodeText(typeArguments));
}
var constructorName = constructorNameNode.name?.name;
if (constructorName != null) {
constantsBuffer.write('.$constructorName');
}
var argumentList = invocation.argumentList;
var arguments = argumentList.arguments;
var argumentCount = arguments.length - (parameterData == null ? 0 : 1);
if (argumentCount == 0) {
if (typeArguments != null || constructorName != null) {
constantsBuffer.write('()');
}
} else if (parameterData == null) {
constantsBuffer.write(utils.getNodeText(argumentList));
} else {
constantsBuffer.write('(');
var index = parameterData.index;
var last = arguments.length - 1;
if (index == 0) {
var offset = arguments[1].offset;
var length = arguments[last].end - offset;
constantsBuffer.write(utils.getText(offset, length));
} else if (index == last) {
var offset = arguments[0].offset;
int length;
if (arguments[last].endToken.next?.type == TokenType.COMMA) {
length = arguments[last].offset - offset;
} else {
length = arguments[last - 1].end - offset;
}
constantsBuffer.write(utils.getText(offset, length));
} else {
var offset = arguments[0].offset;
var length = arguments[index].offset - offset;
constantsBuffer.write(utils.getText(offset, length));
offset = arguments[index + 1].offset;
length = argumentList.endToken.offset - offset;
constantsBuffer.write(utils.getText(offset, length));
}
constantsBuffer.write(')');
}
// Delete the static field that was converted to an enum constant.
_deleteField(builder, field, members);
}
// Remove the index field.
var indexField = fields.indexField;
if (indexField != null) {
_deleteField(builder, indexField, members);
}
// Update the constructors.
var removedConstructor = _removeUnnamedConstructor();
_transformConstructors(builder, removedConstructor);
// Special case replacing all of the members.
if (membersToDelete.length == members.length) {
builder.addSimpleReplacement(range.startEnd(members.first, members.last),
constantsBuffer.toString());
return;
}
// Insert the declarations of the enum constants.
var semicolon = ';';
var prefix = '$eol$indent';
var suffix = '$semicolon$eol';
builder.addSimpleInsertion(bodyOffset, '$prefix$constantsBuffer$suffix');
// Delete any members that are no longer needed.
membersToDelete.sort();
for (var range in range.nodesInList(members, membersToDelete)) {
builder.addDeletion(range);
}
}
/// Use the [builder] to delete the [field].
void _deleteField(DartFileEditBuilder builder, _Field field,
NodeList<ClassMember> members) {
var variableList = field.declarationList;
if (variableList.variables.length == 1) {
membersToDelete.add(members.indexOf(field.fieldDeclaration));
} else {
builder.addDeletion(
range.nodeInList(variableList.variables, field.declaration));
}
}
/// If the unnamed constructor is the only constructor, and if it has no
/// parameters other than potentially the index field, then remove it.
ConstructorDeclaration? _removeUnnamedConstructor() {
var members = classDeclaration.members;
var constructors = members.whereType<ConstructorDeclaration>().toList();
if (constructors.length != 1) {
return null;
}
var constructor = constructors[0];
var name = constructor.name?.name;
if (name != null && name != 'new') {
return null;
}
var parameters = constructor.parameters.parameters;
// If there's only one constructor, then there can only be one entry in the
// constructor map.
var parameterData = constructorMap?.entries.first.value;
// `parameterData` should only be `null` if there is no index field.
var updatedParameterCount =
parameters.length - (parameterData == null ? 0 : 1);
if (updatedParameterCount != 0) {
return null;
}
membersToDelete.add(members.indexOf(constructor));
return constructor;
}
/// Transform the used constructors by removing the parameter corresponding to
/// the index field.
void _transformConstructors(
DartFileEditBuilder builder, ConstructorDeclaration? removedConstructor) {
final constructorMap = this.constructorMap;
if (constructorMap == null) {
return;
}
for (var constructor in constructorMap.keys) {
if (constructor.declaration != removedConstructor) {
var parameterData = constructorMap[constructor];
if (parameterData != null) {
var parameters = constructor.declaration.parameters.parameters;
builder.addDeletion(
range.nodeInList(parameters, parameters[parameterData.index]));
}
}
}
}
/// If the given [node] can be converted into an enum, then return a
/// description of the conversion work to be done. Otherwise, return `null`.
static _EnumDescription? fromClass(ClassDeclaration node) {
// The class must be a concrete class.
var classElement = node.declaredElement;
if (classElement == null || classElement.isAbstract) {
return null;
}
// The class must be a subclass of Object, whether implicitly or explicitly.
var extendsClause = node.extendsClause;
if (extendsClause != null &&
extendsClause.superclass.type?.isDartCoreObject == false) {
return null;
}
// The class must either be private or must only have private constructors.
var constructors = _validateConstructors(node, classElement);
if (constructors == null) {
return null;
}
// The class must not override either `==` or `hashCode`.
if (!_validateMethods(node)) {
return null;
}
// There must be at least one static field that can be converted into an
// enum constant.
//
// The instance fields must all be final.
var fields = _validateFields(node, classElement);
if (fields == null || fields.fieldsToConvert.isEmpty) {
return null;
}
var visitor = _EnumVisitor(classElement, fields.fieldsToConvert);
try {
node.accept(visitor);
} on _CannotConvertException {
return null;
}
// Within the defining library,
// - there can't be any subclasses of the class to be converted,
// - there can't be any invocations of any constructor from that class.
try {
node.root.accept(_NonEnumVisitor(classElement));
} on _CannotConvertException {
return null;
}
var usedConstructors = _computeUsedConstructors(constructors, fields);
var constructorMap = _indexFieldData(usedConstructors, fields);
if (fields.indexField != null && constructorMap == null) {
return null;
}
var membersToDelete = <int>[];
return _EnumDescription(
classDeclaration: node,
constructorMap: constructorMap,
fields: fields,
membersToDelete: membersToDelete,
);
}
/// Return the subset of [constructors] that are invoked by the [fields] to be
/// converted.
static _Constructors _computeUsedConstructors(
_Constructors constructors, _Fields fields) {
var usedElements = <ConstructorElement>{};
for (var field in fields.fieldsToConvert) {
usedElements.add(field.constructorElement);
}
var usedConstructors = _Constructors();
for (var element in usedElements) {
var constructor = constructors.forElement(element);
if (constructor != null) {
usedConstructors.add(constructor);
}
}
return usedConstructors;
}
/// If the index field can be removed, return a map describing the changes
/// that need to be made to both the constructors and the invocations of those
/// constructors. Otherwise, return `null`.
static Map<_Constructor, _Parameter>? _indexFieldData(
_Constructors usedConstructors, _Fields fields) {
var indexField = fields.indexField;
if (indexField == null) {
return null;
}
// Ensure that the index field has a corresponding field formal initializer
// in each of the used constructors.
var constructorMap = <_Constructor, _Parameter>{};
for (var constructor in usedConstructors.constructors) {
var parameterData = _indexParameter(constructor, indexField);
if (parameterData == null) {
return null;
}
constructorMap[constructor] = parameterData;
}
var fieldsToConvert = fields.fieldsToConvert;
var values = <int>{};
for (var field in fieldsToConvert) {
var constructorElement = field.constructorElement;
var constructor = usedConstructors.forElement(constructorElement);
if (constructor == null) {
// We should never reach this point.
return null;
}
var parameterData = constructorMap[constructor];
if (parameterData == null) {
// We should never reach this point.
return null;
}
var arguments = field.instanceCreation.argumentList.arguments;
var argument = parameterData.getArgument(arguments);
if (argument is! IntegerLiteral) {
return null;
}
var value = argument.value;
if (value == null) {
return null;
}
if (!values.add(value)) {
// Duplicate value.
return null;
}
}
var sortedValues = values.toList()..sort();
if (sortedValues.length == fieldsToConvert.length &&
sortedValues.first == 0 &&
sortedValues.last == fieldsToConvert.length - 1) {
return constructorMap;
}
return null;
}
static _Parameter? _indexParameter(
_Constructor constructor, _Field? indexField) {
if (indexField == null) {
return null;
}
var parameters = constructor.declaration.parameters.parameters;
var indexFieldElement = indexField.element;
for (var i = 0; i < parameters.length; i++) {
var element = parameters[i].declaredElement;
if (element is FieldFormalParameterElement) {
if (element.field == indexFieldElement) {
if (element.isPositional) {
return _Parameter(i, element);
} else {
return _Parameter(i, element);
}
}
}
}
return null;
}
/// Return a representation of all of the constructors declared by the
/// [classDeclaration], or `null` if the class can't be converted.
///
/// The [classElement] must be the element declared by the [classDeclaration].
static _Constructors? _validateConstructors(
ClassDeclaration classDeclaration, ClassElement classElement) {
var constructors = _Constructors();
for (var member in classDeclaration.members) {
if (member is ConstructorDeclaration) {
var constructor = member.declaredElement;
if (constructor is ConstructorElement) {
if (!classElement.isPrivate && !constructor.isPrivate) {
// Public constructor in public enum.
return null;
} else if (!constructor.isFactory && !constructor.isConst) {
// Non-const constructor.
return null;
}
constructors.add(_Constructor(member, constructor));
} else {
// Not resolved.
return null;
}
}
}
return constructors;
}
/// Return a representation of all of the constructors declared by the
/// [classDeclaration], or `null` if the class can't be converted.
///
/// The [classElement] must be the element declared by the [classDeclaration].
static _Fields? _validateFields(
ClassDeclaration classDeclaration, ClassElement classElement) {
var potentialFieldsToConvert = <DartObject, List<_ConstantField>>{};
_Field? indexField;
for (var member in classDeclaration.members) {
if (member is FieldDeclaration) {
var fieldList = member.fields;
var fields = fieldList.variables;
if (member.isStatic) {
for (var field in fields) {
var fieldElement = field.declaredElement;
if (fieldElement is FieldElement) {
var fieldType = fieldElement.type;
// The field can be converted to be an enum constant if it
// - is a const field,
// - has a type equal to the type of the class, and
// - is initialized by an instance creation expression defined in this
// class.
if (fieldElement.isConst &&
fieldType is InterfaceType &&
fieldType.element == classElement) {
var initializer = field.initializer;
if (initializer is InstanceCreationExpression) {
var constructorElement =
initializer.constructorName.staticElement;
if (constructorElement != null &&
!constructorElement.isFactory &&
constructorElement.enclosingElement == classElement) {
var fieldValue = fieldElement.computeConstantValue();
if (fieldValue != null) {
if (fieldList.variables.length != 1) {
// Too many constants in the field declaration.
return null;
}
potentialFieldsToConvert
.putIfAbsent(fieldValue, () => [])
.add(_ConstantField(
fieldElement,
field,
fieldList,
member,
initializer,
constructorElement,
fieldValue.getField('index')?.toIntValue() ??
-1));
}
}
}
}
}
}
} else {
for (var field in fields) {
if (!field.isFinal) {
// Non-final instance field.
return null;
}
var fieldElement = field.declaredElement;
if (fieldElement is FieldElement) {
var fieldType = fieldElement.type;
if (fieldElement.name == 'index' && fieldType.isDartCoreInt) {
indexField = _Field(fieldElement, field, fieldList, member);
}
}
}
}
}
}
var fieldsToConvert = <_ConstantField>[];
for (var list in potentialFieldsToConvert.values) {
if (list.length == 1) {
fieldsToConvert.add(list[0]);
} else {
// TODO(brianwilkerson) We could potentially handle the case where
// there's only one non-deprecated field in the list. We'd need to
// change the return type for this method so that we could return two
// lists: the list of fields to convert and the list of fields whose
// initializer needs to be updated to refer to the constant.
return null;
}
}
return _Fields(fieldsToConvert, indexField);
}
/// Return `true` if the [classDeclaration] does not contain any methods that
/// prevent it from being converted.
static bool _validateMethods(ClassDeclaration classDeclaration) {
for (var member in classDeclaration.members) {
if (member is MethodDeclaration) {
if (member.name.name == '==' || member.name.name == 'hashCode') {
return false;
}
}
}
return true;
}
}
/// A visitor used to visit the class being converted. This visitor throws an
/// exception if a constructor for the class is invoked anywhere other than the
/// top-level expression of an initializer for one of the fields being converted.
class _EnumVisitor extends _BaseVisitor {
/// The declarations of the fields that are to be converted.
final List<VariableDeclaration> fieldsToConvert;
/// A flag indicating whether we are currently visiting the children of a
/// field declaration that will be converted to be a constant.
bool inConstantDeclaration = false;
/// Initialize a newly created visitor to visit the class declaration
/// corresponding to the given [classElement].
_EnumVisitor(ClassElement classElement, List<_ConstantField> fieldsToConvert)
: fieldsToConvert =
fieldsToConvert.map((field) => field.declaration).toList(),
super(classElement);
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
if (!inConstantDeclaration) {
if (invokesGenerativeConstructor(node)) {
throw _CannotConvertException(
'Constructor used outside constant initializer');
}
}
inConstantDeclaration = false;
super.visitInstanceCreationExpression(node);
}
@override
void visitVariableDeclaration(VariableDeclaration node) {
if (fieldsToConvert.contains(node)) {
inConstantDeclaration = true;
}
super.visitVariableDeclaration(node);
inConstantDeclaration = false;
}
}
/// A representation of a field of interest in the class being converted.
class _Field {
/// The element representing the field.
final FieldElement element;
/// The declaration of the field.
final VariableDeclaration declaration;
/// The list containing the [declaration]
final VariableDeclarationList declarationList;
/// The field declaration containing the [declarationList].
final FieldDeclaration fieldDeclaration;
_Field(this.element, this.declaration, this.declarationList,
this.fieldDeclaration);
/// Return the name of the field.
String get name => declaration.name.name;
}
/// A representation of all the fields of interest in the class being converted.
class _Fields {
/// The fields to be converted into enum constants.
List<_ConstantField> fieldsToConvert;
/// The index field, or `null` if there is no index field.
_Field? indexField;
_Fields(this.fieldsToConvert, this.indexField);
}
/// A visitor that visits everything in the library other than the class being
/// converted. This visitor throws an exception if the class can't be converted
/// because
/// - there is a subclass of the class, or
/// - there is an invocation of one of the constructors of the class.
class _NonEnumVisitor extends _BaseVisitor {
/// Initialize a newly created visitor to visit everything except the class
/// declaration corresponding to the given [classElement].
_NonEnumVisitor(ClassElement classElement) : super(classElement);
@override
void visitClassDeclaration(ClassDeclaration node) {
var element = node.declaredElement;
if (element == null) {
throw _CannotConvertException('Unresolved');
}
if (element != classElement) {
if (element.supertype?.element == classElement) {
throw _CannotConvertException('Class is extended');
} else if (element.interfaces
.map((e) => e.element)
.contains(classElement)) {
throw _CannotConvertException('Class is implemented');
} else if (element.mixins.map((e) => e.element).contains(classElement)) {
// This case won't occur unless there's an error in the source code, but
// it's easier to check for the condition than it is to check for the
// diagnostic.
throw _CannotConvertException('Class is mixed in');
}
super.visitClassDeclaration(node);
}
}
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
if (invokesGenerativeConstructor(node)) {
throw _CannotConvertException(
'Constructor used outside class being converted');
}
super.visitInstanceCreationExpression(node);
}
}
/// An object used to access information about a specific parameter, including
/// its index in the parameter list as well as any associated argument in an
/// argument list.
class _Parameter {
/// The index of this parameter in the enclosing constructor's parameter list.
final int index;
/// The element associated with the parameter.
final ParameterElement element;
_Parameter(this.index, this.element);
/// Return the expression representing the argument associated with this
/// parameter, or `null` if there is no such argument.
Expression? getArgument(NodeList<Expression> arguments) {
return arguments.firstWhereOrNull(
(argument) => argument.staticParameterElement == element);
}
}