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

2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM google/dart:2.4 as build
FROM drydock-prod.workiva.net/workiva/dart2_base_image:1 as build

# Build Environment Vars
ARG BUILD_ID
Expand Down
176 changes: 154 additions & 22 deletions lib/src/boilerplate_suggestors/boilerplate_utilities.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,87 @@
// 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;

/// Returns the annotation node associated with the provided [classNode]
/// that matches the provided [annotationName], if one exists.
AstNode getAnnotationNode(ClassDeclaration classNode, String annotationName) {
return classNode.metadata.firstWhere(
(node) => node.name.name == annotationName,
orElse: () => null);
}

/// 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);

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

/// A simple evaluation of the annotation(s) of the [classNode]
/// to verify it is a `@StateMixin()`.
bool isAStateMixin(ClassDeclaration classNode) =>
getAnnotationNode(classNode, 'StateMixin') != 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`
/// or `UiState`.
bool extendsFromUiPropsOrUiState(ClassDeclaration classNode) {
return {
'UiProps',
'UiState',
}.contains(classNode.extendsClause.superclass.name.name);
}

/// A simple RegExp against the parent of the class to verify it is `UiProps`
/// or `UiState`.
bool extendsFromUiPropsOrUiState(ClassDeclaration classNode) =>
classNode.extendsClause.superclass.name
.toSource()
.contains(RegExp('(UiProps)|(UiState)'));
bool implementsUiPropsOrUiState(ClassDeclaration classNode) {
return classNode.implementsClause.interfaces
.map((typeName) => typeName.name.name)
.any({'UiProps', 'UiState'}.contains);
}

/// A simple evaluation of the annotation(s) of the [classNode]
/// to verify it is either `@Props()` or `@State()` annotated class.
bool isAPropsOrStateClass(ClassDeclaration classNode) =>
isAPropsClass(classNode) || isAStateClass(classNode);

/// A simple evaluation of the annotation(s) of the [classNode]
/// to verify it is a `@Props()` annotated class.
bool isAPropsClass(ClassDeclaration classNode) =>
getAnnotationNode(classNode, 'Props') != null ||
getAnnotationNode(classNode, 'AbstractProps') != null;

/// A simple RegExp against the name of the class to verify it contains `props`
/// or `state`.
bool isAPropsOrStateClass(ClassDeclaration classNode) => classNode.name
.toSource()
.contains(RegExp('([A-Za-z]+Props)|([A-Za-z]+State)'));
/// A simple evaluation of the annotation(s) of the [classNode]
/// to verify it is a `@State()` annotated class.
bool isAStateClass(ClassDeclaration classNode) =>
getAnnotationNode(classNode, 'State') != null ||
getAnnotationNode(classNode, 'AbstractState') != null;

/// Detects if the Props or State class is considered simple.
///
Expand Down Expand Up @@ -76,9 +128,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 @@ -93,6 +145,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 @@ -104,21 +180,77 @@ void migrateClassToMixin(ClassDeclaration node, YieldPatch yieldPatch,

yieldPatch(node.classKeyword.offset, node.classKeyword.charEnd, 'mixin');

final originalPublicClassName =
stripPrivateGeneratedPrefix(node.name.toSource());
final originalPublicClassName = stripPrivateGeneratedPrefix(node.name.name);
String newMixinName = originalPublicClassName;

yieldPatch(node.name.token.offset,
node.name.token.offset + privateGeneratedPrefix.length, '');
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');
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 (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.firstWhere((interface) =>
interface.name.name == 'UiProps' ||
interface.name.name == 'UiState');
final otherInterfaces = List.of(nodeInterfaces)..remove(uiInterface);

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

if (nodeInterfaces.isNotEmpty) {
// But does implement other stuff
yieldPatch(node.implementsClause.offset, node.implementsClause.end,
'on $uiInterfaceStr implements ${nodeInterfaces.joinByName()}');
}
}
} else {
// Does not implement anything
final uiInterfaceStr = isAPropsMixin(node) ? 'UiProps' : 'UiState';

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

propsAndStateClassNamesConvertedToNewBoilerplate[originalPublicClassName] =
newMixinName;
}

extension IterableAstUtils on Iterable<NamedType> {
/// Utility to join an `Iterable` based on the `name` of the `name` field
/// rather than the `toString()` of the object.
String joinByName(
{String startingString, String endingString, String seperator}) {
final itemString = map((t) => t.name.name).join('${seperator ?? ','} ');
final returnString = StringBuffer()
..write(startingString != null ? '${startingString.trimRight()} ' : '')
..write(itemString)
..write(endingString != null ? '${endingString.trimLeft()}' : '');

return returnString.toString();
}
}
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
72 changes: 72 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,84 @@ 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.firstWhere(
(getter) => getter.name.name == 'props' || getter.name.name == 'state',
orElse: () => null);
if (propsOrStateGetter != null) {
yieldPatch(propsOrStateGetter.offset, propsOrStateGetter.end, '');
}
}

void _removePropsOrStateMixinAnnotation(ClassDeclaration node) {
final propsMixinAnnotationNode = getAnnotationNode(node, 'PropsMixin');
if (propsMixinAnnotationNode != null) {
yieldPatch(
propsMixinAnnotationNode.offset,
// Use the offset of the next token to ensure that any comments that were on the line
// immediately before the annotation we are removing - end up on the line immediately
// before the mixin declaration instead of having a newline separating them.
propsMixinAnnotationNode.endToken.next.offset,
'');
}

final stateMixinAnnotationNode = getAnnotationNode(node, 'StateMixin');
if (stateMixinAnnotationNode != null) {
yieldPatch(
stateMixinAnnotationNode.offset,
// Use the offset of the next token to ensure that any comments that were on the line
// immediately before the annotation we are removing - end up on the line immediately
// before the mixin declaration instead of having a newline separating them.
stateMixinAnnotationNode.endToken.next.offset,
'');
}
}

/// 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.firstWhere(
(field) => field.variables.first.name.name == 'meta',
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 begin = metaFieldDecl.beginToken.precedingComments?.offset ??
metaField.offset;

yieldPatch(begin, metaFieldDecl.end, '');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:codemod/codemod.dart';

import '../util.dart';
import 'boilerplate_utilities.dart';

/// Suggestor that updates props and state classes to new boilerplate.
Expand Down
4 changes: 2 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ authors:
- Sydney Jodon <[email protected]>

environment:
sdk: ">=2.4.0 <3.0.0"
sdk: ">=2.7.0 <3.0.0"

dependencies:
analyzer: '>=0.37.0 <0.39.0'
Expand All @@ -33,7 +33,7 @@ dependencies:

dev_dependencies:
dart_dev: ^2.0.1
dart_style: ^1.2.0
dart_style: ^1.3.3
dependency_validator: ^1.2.3
mockito: ^4.0.0
pedantic: ^1.4.0
Expand Down
Loading