Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CPLAT-9411 Add standalone PropsMixin / StateMixin migrator #77

143 changes: 130 additions & 13 deletions lib/src/boilerplate_suggestors/boilerplate_utilities.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,56 @@
// limitations under the License.

import 'package:analyzer/dart/ast/ast.dart';
import 'package:meta/meta.dart';
import 'package:over_react_codemod/src/constants.dart';
import 'package:over_react_codemod/src/util.dart';

typedef void YieldPatch(
int startingOffset, int endingOffset, String replacement);

@visibleForTesting
bool isPublicForTest = false;

// Stub while <https://jira.atl.workiva.net/browse/CPLAT-9308> is in progress
bool _isPublic(ClassDeclaration node) => false;
bool isPublic(ClassDeclaration node) => isPublicForTest;

/// A simple evaluation of the annotation(s) of the [classNode]
/// to verify it is either a `@PropsMixin()` or `@StateMixin`.
bool isAPropsOrStateMixin(ClassDeclaration classNode) =>
isAPropsMixin(classNode) || isAStateMixin(classNode);

/// Returns the node of a `@PropsMixin()` annotation for the provided [classNode], if one exists.
AstNode getPropsMixinAnnotationNode(ClassDeclaration classNode) =>
classNode.sortedCommentAndAnnotations.singleWhere(
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
(node) => node?.toSource()?.startsWith('@PropsMixin') == true,
orElse: () => null);

/// A simple evaluation of the annotation(s) of the [classNode]
/// to verify it is a `@PropsMixin()`.
bool isAPropsMixin(ClassDeclaration classNode) =>
getPropsMixinAnnotationNode(classNode) != null;

/// Returns the node of a `@PropsMixin()` annotation for the provided [classNode], if one exists.
AstNode getStateMixinAnnotationNode(ClassDeclaration classNode) =>
classNode.sortedCommentAndAnnotations.singleWhere(
(node) => node?.toSource()?.startsWith('@StateMixin') == true,
orElse: () => null);

/// A simple evaluation of the annotation(s) of the [classNode]
/// to verify it is a `@StateMixin()`.
bool isAStateMixin(ClassDeclaration classNode) =>
getStateMixinAnnotationNode(classNode) != null;

/// Whether a props or state mixin class [classNode] should be migrated as part of the boilerplate codemod.
bool shouldMigratePropsAndStateMixin(ClassDeclaration classNode) =>
isAPropsOrStateMixin(classNode);

/// Whether a props or state class class [node] should be migrated as part of the boilerplate codemod.
bool shouldMigratePropsAndStateClass(ClassDeclaration node) {
return isAssociatedWithComponent2(node) &&
isAPropsOrStateClass(node) &&
// Stub while <https://jira.atl.workiva.net/browse/CPLAT-9308> is in progress
!_isPublic(node);
!isPublic(node);
}

/// A simple RegExp against the parent of the class to verify it is `UiProps`
Expand All @@ -37,6 +72,14 @@ bool extendsFromUiPropsOrUiState(ClassDeclaration classNode) =>
.toSource()
.contains(RegExp('(UiProps)|(UiState)'));
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved

/// A simple RegExp against the parent of the class to verify it is `UiProps`
/// or `UiState`.
bool implementsUiPropsOrUiState(ClassDeclaration classNode) {
return classNode.implementsClause.interfaces
.map((typeName) => typeName.toSource())
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
.any((typeStr) => typeStr.contains(RegExp('(UiProps)|(UiState)')));
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
}

/// A simple RegExp against the name of the class to verify it contains `props`
/// or `state`.
bool isAPropsOrStateClass(ClassDeclaration classNode) => classNode.name
Expand Down Expand Up @@ -73,9 +116,9 @@ bool isAdvancedPropsOrStateClass(ClassDeclaration classNode) {
var propsAndStateClassNamesConvertedToNewBoilerplate =
< /*old class name*/ String, /*new mixin name*/ String>{};

/// Used to switch a props or state class to a mixin.
/// Used to switch a props/state class, or a `@PropsMixin()`/`@StateMixin()` class to a mixin.
///
/// __EXAMPLE:__
/// __EXAMPLE (Concrete Class):__
/// ```dart
/// // Before
/// class _$TestProps extends UiProps {
Expand All @@ -90,6 +133,30 @@ var propsAndStateClassNamesConvertedToNewBoilerplate =
/// }
/// ```
///
/// __EXAMPLE (`@PropsMixin`):__
/// ```dart
/// // Before
/// @PropsMixin()
/// abstract class TestPropsMixin implements UiProps, BarPropsMixin {
/// // To ensure the codemod regression checking works properly, please keep this
/// // field at the top of the class!
/// // ignore: undefined_identifier, undefined_class, const_initialized_with_non_constant_value
/// static const PropsMeta meta = _$metaForTestPropsMixin;
///
/// @override
/// Map get props;
///
/// String var1;
/// String var2;
/// }
///
/// // After
/// mixin TestPropsMixin on UiProps implements BarPropsMixin {
/// String var1;
/// String var2;
/// }
/// ```
///
/// When a class is migrated, it gets added to [propsAndStateClassNamesConvertedToNewBoilerplate]
/// so that suggestors that come after the suggestor that called this function - can know
/// whether to yield a patch based on that information.
Expand All @@ -105,15 +172,65 @@ void migrateClassToMixin(ClassDeclaration node, YieldPatch yieldPatch,
stripPrivateGeneratedPrefix(node.name.toSource());
String newMixinName = originalPublicClassName;

yieldPatch(node.name.token.offset,
node.name.token.offset + privateGeneratedPrefix.length, '');

yieldPatch(node.extendsClause.offset,
node.extendsClause.extendsKeyword.charEnd, 'on');

if (shouldAddMixinToName) {
yieldPatch(node.name.token.charEnd, node.name.token.charEnd, 'Mixin');
newMixinName = '${newMixinName}Mixin';
if (node.extendsClause?.extendsKeyword != null) {
// --- Convert concrete props/state class to a mixin --- //

yieldPatch(node.name.token.offset,
node.name.token.offset + privateGeneratedPrefix.length, '');

yieldPatch(node.extendsClause.offset,
node.extendsClause.extendsKeyword.charEnd, 'on');

if (shouldAddMixinToName) {
yieldPatch(node.name.token.charEnd, node.name.token.charEnd, 'Mixin');
newMixinName = '${newMixinName}Mixin';
}
} else {
// --- Convert props/state mixin to an actual mixin --- //

if (node.implementsClause?.implementsKeyword != null) {
final nodeInterfaces = node.implementsClause.interfaces;
// Implements an interface, and does not extend from another class
if (implementsUiPropsOrUiState(node)) {
if (nodeInterfaces.length == 1) {
// Only implements UiProps / UiState
yieldPatch(node.implementsClause.offset,
node.implementsClause.implementsKeyword.charEnd, 'on');
} else {
// Implements UiProps / UiState along with other interfaces
final uiInterface = nodeInterfaces.singleWhere((interface) =>
interface.toSource() == 'UiProps' ||
interface.toSource() == 'UiState');
final otherInterfaces = List.of(nodeInterfaces)..remove(uiInterface);
final otherInterfacesStr =
commaSeparatedAstNodeNames(otherInterfaces);

yieldPatch(node.implementsClause.offset, node.implementsClause.end,
'on ${uiInterface.toSource()} implements $otherInterfacesStr');
}
} else {
// Does not implement UiProps / UiState
final uiInterfaceStr = isAPropsMixin(node) ? 'UiProps' : 'UiState';

if (nodeInterfaces.isNotEmpty) {
// But does implement other stuff
final otherInterfacesStr = commaSeparatedAstNodeNames(nodeInterfaces);

yieldPatch(node.implementsClause.offset, node.implementsClause.end,
'on $uiInterfaceStr implements $otherInterfacesStr');
} else {
// Does not implement anything
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
yieldPatch(node.leftBracket.offset - 1, node.leftBracket.offset - 1,
'on $uiInterfaceStr');
}
}
} else {
// Does not implement anything
final uiInterfaceStr = isAPropsMixin(node) ? 'UiProps' : 'UiState';

yieldPatch(
node.name.token.end, node.name.token.end, ' on $uiInterfaceStr');
}
}

propsAndStateClassNamesConvertedToNewBoilerplate[originalPublicClassName] =
Expand Down
2 changes: 2 additions & 0 deletions lib/src/boilerplate_suggestors/props_meta_migrator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import 'package:over_react_codemod/src/boilerplate_suggestors/boilerplate_utilit
/// // After
/// propsMeta.forMixin(FooProps)
/// ```
///
/// > Related: [PropsMixinMigrator]
class PropsMetaMigrator extends GeneralizingAstVisitor
with AstVisitingSuggestorMixin
implements Suggestor {
Expand Down
66 changes: 66 additions & 0 deletions lib/src/boilerplate_suggestors/props_mixins_migrator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,78 @@ import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:codemod/codemod.dart';

import 'boilerplate_utilities.dart';

/// Suggestor that updates stand alone props mixins to be actual mixins.
///
/// > Related: [PropsMetaMigrator]
class PropsMixinMigrator extends GeneralizingAstVisitor
with AstVisitingSuggestorMixin
implements Suggestor {
@override
visitClassDeclaration(ClassDeclaration node) {
super.visitClassDeclaration(node);

if (!shouldMigratePropsAndStateMixin(node)) return;

migrateClassToMixin(node, yieldPatch);
_removePropsOrStateGetter(node);
_removePropsOrStateMixinAnnotation(node);
_migrateMixinMetaField(node);
}

void _removePropsOrStateGetter(ClassDeclaration node) {
final classGetters = node.members
.whereType<MethodDeclaration>()
.where((method) => method.isGetter);
final propsOrStateGetter = classGetters.singleWhere(
(getter) => getter.name.name == 'props' || getter.name.name == 'state',
orElse: () => null);
if (propsOrStateGetter != null) {
yieldPatch(propsOrStateGetter.offset, propsOrStateGetter.end + 1, '');
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
}
}

void _removePropsOrStateMixinAnnotation(ClassDeclaration node) {
final propsMixinAnnotationNode = getPropsMixinAnnotationNode(node);
if (propsMixinAnnotationNode != null) {
yieldPatch(propsMixinAnnotationNode.offset,
propsMixinAnnotationNode.end + 1, '');
}

final stateMixinAnnotationNode = getStateMixinAnnotationNode(node);
if (stateMixinAnnotationNode != null) {
yieldPatch(stateMixinAnnotationNode.offset,
stateMixinAnnotationNode.end + 1, '');
}
}

/// NOTE: Usage of the meta field elsewhere will be migrated via the [PropsMetaMigrator].
void _migrateMixinMetaField(ClassDeclaration node) {
final classMembers = node.members;
final classFields = classMembers
.whereType<FieldDeclaration>()
.map((decl) => decl.fields)
.toList();
final metaField = classFields.singleWhere(
(field) => field.variables.single.name.name == 'meta',
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
orElse: () => null);
if (metaField == null) return;

if (isPublic(node)) {
yieldPatch(metaField.parent.offset, metaField.parent.offset,
'@Deprecated(\'Use `propsMeta.forMixin(${node.name.name})` instead.\')\n');
} else {
// Remove the meta field, along with any comment lines that preceded it.
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
final metaFieldDecl = metaField.parent;
final previousMember = metaFieldDecl == classMembers.first
? null
: classMembers[classMembers.indexOf(metaFieldDecl) - 1];
final begin = previousMember != null
? previousMember.end + 1
: node.leftBracket.offset + 1;

yieldPatch(begin, metaField.end + 1, '');
}
}
}
11 changes: 11 additions & 0 deletions lib/src/util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -446,3 +446,14 @@ String stripPrivateGeneratedPrefix(String value) {
? value.substring(privateGeneratedPrefix.length)
: value;
}

/// Takes an iterable of AstNodes and returns their name in a comma separated string.
///
/// Optionally, you can pass a function to [getName] if you want something other
/// than [AstNode.toSource] to be used to generate the name for the list.
String commaSeparatedAstNodeNames<T extends AstNode>(Iterable<T> nodeList,
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
{String Function(T node) getName}) {
getName ??= (node) => node.toSource();

return nodeList.map(getName).toString().replaceAll(RegExp(r'[\(\)]'), '');
aaronlademann-wf marked this conversation as resolved.
Show resolved Hide resolved
}
Loading