Skip to content

Commit

Permalink
Merge pull request #302 from Workiva/connect-required-props
Browse files Browse the repository at this point in the history
FED-3143 Null Safety Codemod for connect props
  • Loading branch information
rmconsole4-wk authored Oct 23, 2024
2 parents e99d2cc + 2e2e684 commit 86c480a
Show file tree
Hide file tree
Showing 7 changed files with 433 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart';
import 'package:over_react_codemod/src/util.dart';
import 'package:over_react_codemod/src/util/component_usage.dart';
import 'package:analyzer/dart/ast/ast.dart';
Expand Down Expand Up @@ -103,11 +104,7 @@ class ClassComponentRequiredDefaultPropsMigrator
// If this cascade is not assigning values to defaultProps, bail.
if (!isDefaultProps) return;

final cascadedDefaultProps = node.cascadeSections
.whereType<AssignmentExpression>()
.where((assignment) => assignment.leftHandSide is PropertyAccess)
.map((assignment) => PropAssignment(assignment))
.where((prop) => prop.node.writeElement?.displayName != null);
final cascadedDefaultProps = getCascadedProps(node);

patchFieldDeclarations(
getAllProps, cascadedDefaultProps, node, _propRequirednessRecommender);
Expand Down
137 changes: 137 additions & 0 deletions lib/src/dart3_suggestors/null_safety_prep/connect_required_props.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright 2024 Workiva Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:collection/collection.dart';
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart';
import 'package:over_react_codemod/src/util.dart';
import 'package:over_react_codemod/src/util/class_suggestor.dart';

import 'analyzer_plugin_utils.dart';

/// Suggestor that adds `@Props(disableRequiredPropValidation: {...})` annotations
/// for props that are set in `connect` components.
class ConnectRequiredProps extends RecursiveAstVisitor with ClassSuggestor {
/// Running list of props that should be ignored per mixin that will all be added
/// at the end in [generatePatches].
final _ignoredPropsByMixin = <InterfaceElement, Set<String>>{};

@override
visitCascadeExpression(CascadeExpression node) {
super.visitCascadeExpression(node);

// Verify the builder usage is within the `connect` method call.
final connect = node.thisOrAncestorMatching<MethodInvocation>(
(n) => n is MethodInvocation && n.methodName.name == 'connect');
if (connect == null) return;

// Verify the builder usage is within one of the targeted connect args.
final connectArgs =
connect.argumentList.arguments.whereType<NamedExpression>();
final connectArg = node.thisOrAncestorMatching<NamedExpression>((n) =>
n is NamedExpression &&
connectArgs.contains(n) &&
connectArgNames.contains(n.name.label.name));
if (connectArg == null) return;

final cascadedProps = getCascadedProps(node).toList();

for (final field in cascadedProps) {
final propsElement =
node.staticType?.typeOrBound.tryCast<InterfaceType>()?.element;
if (propsElement == null) continue;

// Keep a running list of props to ignore per props mixin.
final fieldName = field.name.name;
_ignoredPropsByMixin.putIfAbsent(propsElement, () => {}).add(fieldName);
}
}

@override
Future<void> generatePatches() async {
_ignoredPropsByMixin.clear();
final result = await context.getResolvedUnit();
if (result == null) {
throw Exception(
'Could not get resolved result for "${context.relativePath}"');
}
result.unit.accept(this);

// Add the patches at the end so that all the props to be ignored can be collected
// from the different args in `connect` before adding patches to avoid duplicate patches.
_ignoredPropsByMixin.forEach((propsClass, propsToIgnore) {
final classNode =
NodeLocator2(propsClass.nameOffset).searchWithin(result.unit);
if (classNode != null && classNode is NamedCompilationUnitMember) {
final existingAnnotation =
classNode.metadata.where((c) => c.name.name == 'Props').firstOrNull;

if (existingAnnotation == null) {
// Add full @Props annotation if it doesn't exist.
yieldPatch(
'@Props($annotationArg: {${propsToIgnore.map((p) => '\'$p\'').join(', ')}})\n',
classNode.offset,
classNode.offset);
} else {
final existingAnnotationArg = existingAnnotation.arguments?.arguments
.whereType<NamedExpression>()
.where((e) => e.name.label.name == annotationArg)
.firstOrNull;

if (existingAnnotationArg == null) {
// Add disable validation arg to existing @Props annotation.
final offset = existingAnnotation.arguments?.leftParenthesis.end;
if (offset != null) {
yieldPatch(
'$annotationArg: {${propsToIgnore.map((p) => '\'$p\'').join(', ')}}${existingAnnotation.arguments?.arguments.isNotEmpty ?? false ? ', ' : ''}',
offset,
offset);
}
} else {
// Add props to disable validation for to the existing list of disabled
// props in the @Props annotation if they aren't already listed.
final existingList =
existingAnnotationArg.expression.tryCast<SetOrMapLiteral>();
if (existingList != null) {
final alreadyIgnored = existingList.elements
.whereType<SimpleStringLiteral>()
.map((e) => e.stringValue)
.toList();
final newPropsToIgnore =
propsToIgnore.where((p) => !alreadyIgnored.contains(p));
if (newPropsToIgnore.isNotEmpty) {
final offset = existingList.leftBracket.end;
yieldPatch(
'${newPropsToIgnore.map((p) => '\'$p\'').join(', ')}, ',
offset,
offset);
}
}
}
}
}
});
}

static const connectArgNames = [
'mapStateToProps',
'mapStateToPropsWithOwnProps',
'mapDispatchToProps',
'mapDispatchToPropsWithOwnProps',
];
static const annotationArg = 'disableRequiredPropValidation';
}
11 changes: 11 additions & 0 deletions lib/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:over_react_codemod/src/util/component_usage.dart';

/// Returns a list of props from [cascade].
Iterable<PropAssignment> getCascadedProps(CascadeExpression cascade) {
return cascade.cascadeSections
.whereType<AssignmentExpression>()
.where((assignment) => assignment.leftHandSide is PropertyAccess)
.map((assignment) => PropAssignment(assignment))
.where((prop) => prop.node.writeElement?.displayName != null);
}
14 changes: 14 additions & 0 deletions lib/src/executables/null_safety_migrator_companion.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'dart:io';
import 'package:args/args.dart';
import 'package:codemod/codemod.dart';
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart';
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/connect_required_props.dart';
import 'package:over_react_codemod/src/util.dart';

import '../dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.dart';
Expand Down Expand Up @@ -78,4 +79,17 @@ void main(List<String> args) async {
additionalHelpOutput: parser.usage,
changesRequiredOutput: _changesRequiredOutput,
);

if (exitCode != 0) return;

exitCode = await runInteractiveCodemodSequence(
dartPaths,
[
ConnectRequiredProps(),
],
defaultYes: true,
args: parsedArgs.rest,
additionalHelpOutput: parser.usage,
changesRequiredOutput: _changesRequiredOutput,
);
}
Loading

0 comments on commit 86c480a

Please sign in to comment.