Skip to content

Commit

Permalink
Merge pull request #77 from Workiva/CPLAT-9205-new-boilerplate-codemo…
Browse files Browse the repository at this point in the history
…d__CPLAT-9411

CPLAT-9411 Add standalone PropsMixin / StateMixin migrator
  • Loading branch information
greglittlefield-wf authored Feb 6, 2020
2 parents 04963d0 + 260e55c commit 9f53684
Show file tree
Hide file tree
Showing 7 changed files with 585 additions and 26 deletions.
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.
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

0 comments on commit 9f53684

Please sign in to comment.