Skip to content

Commit

Permalink
Merge pull request #268 from Workiva/batch/fedx/FED-1919_required_flu…
Browse files Browse the repository at this point in the history
…x_actions

FED-1919 Codemod to add required flux actions / store prop(s)
  • Loading branch information
rmconsole3-wf authored Jan 5, 2024
2 parents bb73b48 + 7798be7 commit b4dbaad
Show file tree
Hide file tree
Showing 12 changed files with 2,020 additions and 19 deletions.
15 changes: 15 additions & 0 deletions bin/required_flux_props.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2023 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.

export 'package:over_react_codemod/src/executables/required_flux_props.dart';
268 changes: 268 additions & 0 deletions lib/src/dart3_suggestors/null_safety_prep/required_flux_props.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
// Copyright 2023 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/analysis/results.dart';
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:meta/meta.dart';
import 'package:over_react_codemod/src/util.dart';
import 'package:over_react_codemod/src/util/class_suggestor.dart';

/// Suggestor that adds required `store` and/or `actions` prop(s) to the
/// call-site of `FluxUiComponent` instances that omit them since version
/// 5.0.0 of over_react makes flux `store`/`actions` props required.
///
/// In the case of a component that is rendered in a scope where a store/actions
/// instance is available, but simply not passed along to the component, those
/// instance(s) will be used as the value for `props.store`/`props.actions`,
/// even though the component itself may not make use of them internally.
///
/// In the case of a component that is rendered in a scope where a store/actions
/// instance is not available, `null` will be used as the value for the prop(s).
class RequiredFluxProps extends RecursiveAstVisitor with ClassSuggestor {
ResolvedUnitResult? _result;

static const fluxPropsMixinName = 'FluxUiPropsMixin';

@visibleForTesting
static String getTodoForPossiblyValidStoreVar(String fluxStoreVarName) {
return ' // TODO: There is a valid flux store value in scope that could be set here (`$fluxStoreVarName`). Should it be set?';
}

@override
visitCascadeExpression(CascadeExpression node) {
final cascadeWriteEl = node.staticType?.element;
if (cascadeWriteEl is! ClassElement) return;
const typesToIgnore = {
'_PanelTitleProps',
'PanelTitleProps',
'PanelTitleV2Props',
'_PanelToolbarProps',
'PanelToolbarProps',
};
if (typesToIgnore.contains(cascadeWriteEl.name)) {
return;
}
final isReturnedAsDefaultProps = node.ancestors
.whereType<MethodDeclaration>()
.firstOrNull
?.name
.lexeme
.contains(RegExp(r'getDefaultProps|defaultProps')) ??
false;
if (isReturnedAsDefaultProps) return;

final maybeFluxUiPropsMixin = cascadeWriteEl.mixins
.singleWhereOrNull((e) => e.element.name == fluxPropsMixinName);
if (maybeFluxUiPropsMixin == null) return;

final fluxActionsType = maybeFluxUiPropsMixin.typeArguments[0];
final fluxStoreType = maybeFluxUiPropsMixin.typeArguments[1];

final cascadingAssignments =
node.cascadeSections.whereType<AssignmentExpression>();
var storeAssigned = cascadingAssignments.any((cascade) {
final lhs = cascade.leftHandSide;
return lhs is PropertyAccess && lhs.propertyName.name == 'store';
});
var actionsAssigned = cascadingAssignments.any((cascade) {
final lhs = cascade.leftHandSide;
return lhs is PropertyAccess && lhs.propertyName.name == 'actions';
});

if (!storeAssigned) {
storeAssigned = true;
final storeValue =
_getNameOfVarOrFieldInScopeWithType(node, fluxStoreType);
if (storeValue != null) {
final todoComment = getTodoForPossiblyValidStoreVar(storeValue);
yieldNewCascadeSection(node, '$todoComment\n..store = null');
} else {
yieldNewCascadeSection(node, '..store = null');
}
}

if (!actionsAssigned) {
actionsAssigned = true;
final actionsValue =
_getNameOfVarOrFieldInScopeWithType(node, fluxActionsType) ?? 'null';
yieldNewCascadeSection(node, '..actions = $actionsValue');
}
}

void yieldNewCascadeSection(CascadeExpression node, String newSection) {
final offset = node.target.end;
yieldPatch(newSection, offset, offset);
}

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

class InScopeVariable {
final String name;
final DartType? type;

InScopeVariable(this.name, this.type);
}

String? _getNameOfVarOrFieldInScopeWithType(AstNode node, DartType type) {
if (type is DynamicType || type.isDartCoreNull) return null;

final mostInScopeVariables = node.ancestors.expand((ancestor) sync* {
if (ancestor is FunctionDeclaration) {
// Function arguments
final element = ancestor.declaredElement;
if (element != null) {
yield* element.parameters.map((p) => InScopeVariable(p.name, p.type));
}
} else if (ancestor is Block) {
// Variables declared in the block (function body, if/else block, etc.)
yield* ancestor.statements
.whereType<VariableDeclarationStatement>()
.expand((d) => d.variables.variables)
.map((v) => InScopeVariable(v.name.lexeme, v.declaredElement?.type));
} else if (ancestor is ClassDeclaration) {
// Class fields
final element = ancestor.declaredElement;
if (element != null) {
yield* element.fields.map((f) => InScopeVariable(f.name, f.type));
}
} else if (ancestor is CompilationUnit) {
// Top-level variables
yield* ancestor.declarations
.whereType<TopLevelVariableDeclaration>()
.expand((d) => d.variables.variables)
.map((v) => InScopeVariable(v.name.lexeme, v.declaredElement?.type));
}
});

// Usually we'd grab typeSystem from the ResolvedUnitResult, but we don't have access to that
// in this class, so just get it from the compilation unit.
final typeSystem =
(node.root as CompilationUnit).declaredElement!.library.typeSystem;
bool isMatchingType(DartType? maybeMatchingType) =>
maybeMatchingType != null &&
maybeMatchingType is! DynamicType &&
typeSystem.isAssignableTo(maybeMatchingType, type);

final inScopeVarName = mostInScopeVariables
.firstWhereOrNull((v) => isMatchingType(v.type))
?.name;

final componentScopePropDetector = _ComponentScopeFluxPropsDetector();
// Find actions/store in props of class components
componentScopePropDetector.handlePotentialClassComponent(
node.thisOrAncestorOfType<ClassDeclaration>());
// Find actions/store in props of fn components
componentScopePropDetector.handlePotentialFunctionComponent(
node.thisOrAncestorOfType<MethodInvocation>());

final inScopePropName =
componentScopePropDetector.found.firstWhereOrNull((el) {
final maybeMatchingType = componentScopePropDetector.getAccessorType(el);
return maybeMatchingType?.element?.name == type.element?.name;
})?.name;

if (inScopeVarName != null && inScopePropName != null) {
// TODO: Do we need to handle this edge case with something better than returning null?
// No way to determine which should be used - the scoped variable or the field on props
// so return null to avoid setting the incorrect value on the consumer's code.
return null;
}

if (inScopePropName != null) {
return '${componentScopePropDetector.propsName}.${inScopePropName}';
}

return inScopeVarName;
}

bool _isFnComponentDeclaration(Expression? varInitializer) =>
varInitializer is MethodInvocation &&
varInitializer.methodName.name.startsWith('uiF');

/// A visitor to detect store/actions values in a props class (supports both class and fn components)
class _ComponentScopeFluxPropsDetector {
final Map<PropertyAccessorElement, DartType> _foundWithMappedTypes;

List<PropertyAccessorElement> get found =>
_foundWithMappedTypes.keys.toList();

_ComponentScopeFluxPropsDetector() : _foundWithMappedTypes = {};

String _propsName = 'props';

/// The name of the function component props arg, or the class component `props` instance field.
String get propsName => _propsName;

DartType? getAccessorType(PropertyAccessorElement el) =>
_foundWithMappedTypes[el];

void _lookForFluxStoreAndActionsInPropsClass(Element? elWithProps) {
if (elWithProps is ClassElement) {
final fluxPropsEl = elWithProps.mixins.singleWhereOrNull(
(e) => e.element.name == RequiredFluxProps.fluxPropsMixinName);

if (fluxPropsEl != null) {
final actionsType = fluxPropsEl.typeArguments[0];
final storeType = fluxPropsEl.typeArguments[1];
fluxPropsEl.accessors.forEach((a) {
final accessorTypeName = a.declaration.variable.type.element?.name;
if (accessorTypeName == 'ActionsT') {
_foundWithMappedTypes.putIfAbsent(a.declaration, () => actionsType);
} else if (accessorTypeName == 'StoresT') {
_foundWithMappedTypes.putIfAbsent(a.declaration, () => storeType);
}
});
}
}
}

/// Visit function components
void handlePotentialFunctionComponent(MethodInvocation? node) {
if (node == null) return;
if (!_isFnComponentDeclaration(node)) return;

final nodeType = node.staticType;
if (nodeType is FunctionType) {
final propsArg =
node.argumentList.arguments.firstOrNull as FunctionExpression?;
final propsArgName =
propsArg?.parameters?.parameterElements.firstOrNull?.name;
if (propsArgName != null) {
_propsName = propsArgName;
}
_lookForFluxStoreAndActionsInPropsClass(nodeType.returnType.element);
}
}

/// Visit composite (class) components
void handlePotentialClassComponent(ClassDeclaration? node) {
if (node == null) return;
final elWithProps =
node.declaredElement?.supertype?.typeArguments.singleOrNull?.element;
_lookForFluxStoreAndActionsInPropsClass(elWithProps);
}
}
60 changes: 60 additions & 0 deletions lib/src/executables/required_flux_props.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2023 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 'dart:io';

import 'package:args/args.dart';
import 'package:codemod/codemod.dart';
import 'package:logging/logging.dart';
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/required_flux_props.dart';
import 'package:over_react_codemod/src/ignoreable.dart';
import 'package:over_react_codemod/src/util.dart';
import 'package:over_react_codemod/src/util/package_util.dart';

const _changesRequiredOutput = """
To update your code, run the following commands in your repository:
pub global activate over_react_codemod
pub global run over_react_codemod:required_flux_props
""";

final _log = Logger('orcm.required_flux_props');

Future<void> pubGetForAllPackageRoots(Iterable<String> files) async {
_log.info(
'Running `pub get` if needed so that all Dart files can be resolved...');
final packageRoots = files.map(findPackageRootFor).toSet();
for (final packageRoot in packageRoots) {
await runPubGetIfNeeded(packageRoot);
}
}

void main(List<String> args) async {
final parser = ArgParser.allowAnything();

final parsedArgs = parser.parse(args);
final dartPaths = allDartPathsExceptHidden();

await pubGetForAllPackageRoots(dartPaths);

exitCode = await runInteractiveCodemod(
dartPaths,
aggregate([
RequiredFluxProps(),
].map((s) => ignoreable(s))),
defaultYes: true,
args: parsedArgs.rest,
additionalHelpOutput: parser.usage,
changesRequiredOutput: _changesRequiredOutput,
);
}
4 changes: 2 additions & 2 deletions lib/src/intl_suggestors/intl_migrator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ class ConstantStringMigrator extends GeneralizingAstVisitor
if (node.isConst &&
node.initializer != null &&
node.initializer is SimpleStringLiteral) {
SimpleStringLiteral literal = node.initializer as SimpleStringLiteral;
SimpleStringLiteral literal = node.initializer! as SimpleStringLiteral;
var string = literal.stringValue;
// I don't see how the parent could possibly be null, but if it's true, bail out.
if (node.parent == null || string == null || string.length <= 1) return;
Expand Down Expand Up @@ -147,7 +147,7 @@ class ConstantStringMigrator extends GeneralizingAstVisitor
} else {
// Use a content-based name.
var contentBasedName =
toVariableName(stringContent(node.initializer as StringLiteral)!);
toVariableName(stringContent(node.initializer! as StringLiteral)!);
return contentBasedName;
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/src/intl_suggestors/message_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class MessageParser {
String withCorrectedNameParameter(MethodDeclaration declaration) {
var invocation = intlMethodInvocation(declaration);
var nameParameter = nameParameterFrom(invocation);
var className = (declaration.parent as ClassDeclaration).name.lexeme;
var className = (declaration.parent! as ClassDeclaration).name.lexeme;
var expected = "'${className}_${declaration.name.lexeme}'";
var actual = nameParameter?.expression.toSource();
var basicString = '$declaration';
Expand Down
2 changes: 1 addition & 1 deletion lib/src/react16_suggestors/react_style_maps_updater.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class ReactStyleMapsUpdater extends GeneralizingAstVisitor
// Handle `toRem(1).toString()`
if (invocation.methodName.name == 'toString' &&
invocation.target is MethodInvocation) {
invocation = invocation.target as MethodInvocation;
invocation = invocation.target! as MethodInvocation;
}

if (!const ['toPx', 'toRem'].contains(invocation.methodName.name)) {
Expand Down
Loading

0 comments on commit b4dbaad

Please sign in to comment.