diff --git a/.github/workflows/dart_ci.yml b/.github/workflows/dart_ci.yml index 12c9c56a..1176fa81 100644 --- a/.github/workflows/dart_ci.yml +++ b/.github/workflows/dart_ci.yml @@ -65,3 +65,9 @@ jobs: echo 'Running dart pub get in test fixtures beforehand to prevent concurrent `dart pub get`s in tests from failing' (cd test/test_fixtures/over_react_project && dart pub get) dart test --exclude-tags=wsd + + - name: Create SBOM Release Asset + uses: anchore/sbom-action@v0 + with: + path: ./ + format: cyclonedx-json diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 04e8e710..00000000 --- a/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -FROM drydock-prod.workiva.net/workiva/dart2_base_image:2 as build - -# Build Environment Vars -ARG BUILD_ID -ARG BUILD_NUMBER -ARG BUILD_URL -ARG GIT_COMMIT -ARG GIT_BRANCH -ARG GIT_TAG -ARG GIT_COMMIT_RANGE -ARG GIT_HEAD_URL -ARG GIT_MERGE_HEAD -ARG GIT_MERGE_BRANCH -# Expose env vars for git ssh access -ARG GIT_SSH_KEY -ARG KNOWN_HOSTS_CONTENT - -WORKDIR /build/ -ADD . /build/ - -# Install SSH keys for git ssh access -RUN mkdir /root/.ssh -RUN echo "$KNOWN_HOSTS_CONTENT" > "/root/.ssh/known_hosts" -RUN echo "$GIT_SSH_KEY" > "/root/.ssh/id_rsa" -RUN chmod 700 /root/.ssh/ -RUN chmod 600 /root/.ssh/id_rsa - -RUN dart pub get - -ARG BUILD_ARTIFACTS_BUILD=/build/pubspec.lock -FROM scratch diff --git a/bin/null_safety_migrator_companion.dart b/bin/null_safety_migrator_companion.dart new file mode 100644 index 00000000..bf2b0410 --- /dev/null +++ b/bin/null_safety_migrator_companion.dart @@ -0,0 +1,15 @@ +// 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. + +export 'package:over_react_codemod/src/executables/null_safety_migrator_companion.dart'; diff --git a/lib/src/dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.dart b/lib/src/dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.dart index 924b47b4..dbdc16ed 100644 --- a/lib/src/dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.dart +++ b/lib/src/dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.dart @@ -18,6 +18,7 @@ import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:collection/collection.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart'; import 'package:over_react_codemod/src/util/component_usage.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; @@ -74,8 +75,8 @@ class CallbackRefHintSuggestor extends RecursiveAstVisitor final param = rhs.parameters?.parameters.first; if (param is SimpleFormalParameter) { final type = param.type; - if (type != null && !_hintAlreadyExists(type)) { - yieldPatch(nullabilityHint, type.end, type.end); + if (type != null && !nullableHintAlreadyExists(type)) { + yieldPatch(nullableHint, type.end, type.end); } } @@ -109,9 +110,9 @@ class CallbackRefHintSuggestor extends RecursiveAstVisitor .tryCast() ?.type; if (varType != null && - !_hintAlreadyExists(varType) && + !nullableHintAlreadyExists(varType) && varType.toSource() != 'dynamic') { - yieldPatch(nullabilityHint, varType.end, varType.end); + yieldPatch(nullableHint, varType.end, varType.end); } } } @@ -123,9 +124,9 @@ class CallbackRefHintSuggestor extends RecursiveAstVisitor final refCasts = allDescendantsOfType(rhs.body).where( (expression) => expression.expression.toSource() == refParamName && - !_hintAlreadyExists(expression.type)); + !nullableHintAlreadyExists(expression.type)); for (final cast in refCasts) { - yieldPatch(nullabilityHint, cast.type.end, cast.type.end); + yieldPatch(nullableHint, cast.type.end, cast.type.end); } } } @@ -143,14 +144,3 @@ class CallbackRefHintSuggestor extends RecursiveAstVisitor result.unit.visitChildren(this); } } - -/// Whether the nullability hint already exists after [type]. -bool _hintAlreadyExists(TypeAnnotation type) { - // The nullability hint will follow the type so we need to check the next token to find the comment if it exists. - return type.endToken.next?.precedingComments - ?.value() - .contains(nullabilityHint) ?? - false; -} - -const nullabilityHint = '/*?*/'; diff --git a/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart new file mode 100644 index 00000000..e3f9c425 --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart @@ -0,0 +1,110 @@ +// Adapted from the missing_required_prop diagnostic in over_react/analyzer_plugin +// Permalink: https://github.com/Workiva/over_react/blob/ae8c898650537e49f35f98ad1b065c516207838e/tools/analyzer_plugin/lib/src/util/prop_declarations/defaulted_props.dart + +// 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:over_react_codemod/src/util.dart'; +import 'package:over_react_codemod/src/util/component_usage.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:over_react_codemod/src/util/get_all_props.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import 'utils/class_component_required_fields.dart'; + +/// Suggestor to assist with preparations for null-safety by adding +/// "requiredness" (`late`) / nullability (`?`/`!`) hints to prop types +/// based on their access within a class component's `defaultProps`. +/// +/// If a prop is defaulted to a non-null value within `defaultProps`, the +/// corresponding prop declaration will gain a `/*late*/` modifier hint to the +/// left of the type, and a non-nullable type hint (`/*!*/`) to the right of +/// the type to assist the `nnbd_migration:migrate` script when it attempts to +/// infer a prop's nullability. +/// +/// **Optionally**, an [sdkVersion] can be passed to the constructor. +/// When set to a version that opts-in to Dart's null safety feature, +/// the `late` / `?` type modifiers will be actual modifiers rather +/// than commented hints. This should only be done using an explicit opt-in +/// flag from the executable as most consumers that have migrated to null-safety +/// will have already run this script prior to the null safety migration and thus +/// the `/*late*/` / `/*?*/` hints will already be converted to actual modifiers. +/// +/// **Before** +/// ```dart +/// mixin FooProps on UiProps { +/// String defaultedNullable; +/// num defaultedNonNullable; +/// } +/// class FooComponent extends UiComponent2 { +/// @override +/// get defaultProps => (newProps() +/// ..defaultedNullable = null +/// ..defaultedNonNullable = 2.1 +/// ); +/// +/// // ... +/// } +/// ``` +/// +/// **After** +/// ```dart +/// mixin FooProps on UiProps { +/// /*late*/ String/*?*/ defaultedNullable; +/// /*late*/ num/*!*/ defaultedNonNullable; +/// } +/// class FooComponent extends UiComponent2 { +/// @override +/// get defaultProps => (newProps() +/// ..defaultedNullable = null +/// ..defaultedNonNullable = 2.1 +/// ); +/// +/// // ... +/// } +/// ``` +class ClassComponentRequiredDefaultPropsMigrator + extends ClassComponentRequiredFieldsMigrator { + ClassComponentRequiredDefaultPropsMigrator([Version? sdkVersion]) + : super('defaultProps', 'getDefaultProps', sdkVersion); + + @override + Future visitCascadeExpression(CascadeExpression node) async { + super.visitCascadeExpression(node); + + final isDefaultProps = node.ancestors.any((ancestor) { + if (ancestor is MethodDeclaration) { + return [relevantGetterName, relevantMethodName] + .contains(ancestor.declaredElement?.name); + } + if (ancestor is VariableDeclaration && + (ancestor.parentFieldDeclaration?.isStatic ?? false)) { + return RegExp(RegExp.escape(relevantGetterName), caseSensitive: false) + .hasMatch(ancestor.name.lexeme); + } + return false; + }); + + // If this cascade is not assigning values to defaultProps, bail. + if (!isDefaultProps) return; + + final cascadedDefaultProps = node.cascadeSections + .whereType() + .where((assignment) => assignment.leftHandSide is PropertyAccess) + .map((assignment) => PropAssignment(assignment)) + .where((prop) => prop.node.writeElement?.displayName != null); + + patchFieldDeclarations(getAllProps, cascadedDefaultProps, node); + } +} diff --git a/lib/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart new file mode 100644 index 00000000..525808b7 --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart @@ -0,0 +1,100 @@ +// Adapted from the missing_required_prop diagnostic in over_react/analyzer_plugin +// Permalink: https://github.com/Workiva/over_react/blob/ae8c898650537e49f35f98ad1b065c516207838e/tools/analyzer_plugin/lib/src/util/prop_declarations/defaulted_props.dart + +// 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:over_react_codemod/src/util/component_usage.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:over_react_codemod/src/util/get_all_props.dart'; +import 'package:over_react_codemod/src/util/get_all_state.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import 'utils/class_component_required_fields.dart'; + +/// Suggestor to assist with preparations for null-safety by adding +/// "requiredness" (`late`) / nullability (`?`/`!`) hints to state field types +/// based on their access within a class component's `initialState`. +/// +/// If a piece of state is initialized to a non-null value within `initialState`, +/// the corresponding declaration will gain a `/*late*/` modifier hint to +/// the left of the type, and a non-nullable type hint (`/*!*/`) to the right of +/// the type to assist the `nnbd_migration:migrate` script when it attempts to +/// infer a state field's nullability. +/// +/// **Optionally**, an [sdkVersion] can be passed to the constructor. +/// When set to a version that opts-in to Dart's null safety feature, +/// the `late` / `?` type modifiers will be actual modifiers rather +/// than commented hints. This should only be done using an explicit opt-in +/// flag from the executable as most consumers that have migrated to null-safety +/// will have already run this script prior to the null safety migration and thus +/// the `/*late*/` / `/*?*/` hints will already be converted to actual modifiers. +/// +/// **Before** +/// ```dart +/// mixin FooState on UiState { +/// String defaultedNullable; +/// num defaultedNonNullable; +/// } +/// class FooComponent extends UiStatefulComponent2 { +/// @override +/// get initialState => (newState() +/// ..defaultedNullable = null +/// ..defaultedNonNullable = 2.1 +/// ); +/// +/// // ... +/// } +/// ``` +/// +/// **After** +/// ```dart +/// mixin FooState on UiState { +/// /*late*/ String/*?*/ defaultedNullable; +/// /*late*/ num/*!*/ defaultedNonNullable; +/// } +/// class FooComponent extends UiStatefulComponent2 { +/// @override +/// get initialState => (newState() +/// ..defaultedNullable = null +/// ..defaultedNonNullable = 2.1 +/// ); +/// +/// // ... +/// } +/// ``` +class ClassComponentRequiredInitialStateMigrator + extends ClassComponentRequiredFieldsMigrator { + ClassComponentRequiredInitialStateMigrator([Version? sdkVersion]) + : super('initialState', 'getInitialState', sdkVersion); + + @override + Future visitCascadeExpression(CascadeExpression node) async { + super.visitCascadeExpression(node); + + final isInitialState = [relevantGetterName, relevantMethodName].contains( + node.thisOrAncestorOfType()?.declaredElement?.name); + + // If this cascade is not assigning values to defaultProps, bail. + if (!isInitialState) return; + + final cascadedInitialState = node.cascadeSections + .whereType() + .where((assignment) => assignment.leftHandSide is PropertyAccess) + .map((assignment) => StateAssignment(assignment)) + .where((prop) => prop.node.writeElement?.displayName != null); + + patchFieldDeclarations(getAllState, cascadedInitialState, node); + } +} diff --git a/lib/src/dart3_suggestors/null_safety_prep/fn_prop_null_aware_call_suggestor.dart b/lib/src/dart3_suggestors/null_safety_prep/fn_prop_null_aware_call_suggestor.dart new file mode 100644 index 00000000..9bbc011e --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/fn_prop_null_aware_call_suggestor.dart @@ -0,0 +1,177 @@ +// 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/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:over_react_codemod/src/util.dart'; +import 'package:over_react_codemod/src/util/class_suggestor.dart'; + +/// Suggestor that replaces conditional calls to functions declared in props +/// with inline null-aware property access. +/// +/// This is helpful for null-safety migrations because the conditional +/// function calls will otherwise get migrated with `!` modifiers. +/// +/// **Before:** +/// +/// ```dart +/// if (props.someCallback != null) { +/// props.someCallback(someValue); +/// } +/// +/// // Will be migrated to: +/// if (props.someCallback != null) { +/// props.someCallback!(someValue); +/// } +/// ``` +/// +/// **After:** +/// +/// ```dart +/// // This will require no changes during a null-safety migration. +/// props.someCallback?.call(someValue); +/// ``` +class FnPropNullAwareCallSuggestor extends RecursiveAstVisitor + with ClassSuggestor { + ResolvedUnitResult? _result; + + @override + visitExpressionStatement(ExpressionStatement node) { + super.visitExpressionStatement(node); + + if (node.expression is! BinaryExpression) return; + + final relevantExprStatement = + _getPropFunctionExpressionBeingCalledConditionally( + node.expression as BinaryExpression); + final inlineBinaryExpr = + // This cast is safe due to the type checks within `_getPropFunctionExpressionBeingCalledConditionally`. + relevantExprStatement?.expression as BinaryExpression?; + if (inlineBinaryExpr == null) return; + final relevantFnExpr = + // This cast is safe due to the type checks within `_getPropFunctionExpressionBeingCalledConditionally`. + inlineBinaryExpr.rightOperand as FunctionExpressionInvocation; + // This cast is safe due to the type checks within `_getPropFunctionExpressionBeingCalledConditionally`. + final fn = relevantFnExpr.function as PropertyAccess; + + yieldPatch( + '${fn.target}.${fn.propertyName}?.call${relevantFnExpr.argumentList};', + node.offset, + node.end); + } + + @override + visitIfStatement(IfStatement node) { + super.visitIfStatement(node); + + if (node.condition is! BinaryExpression) return; + + final relevantFnExprStatement = + _getPropFunctionExpressionBeingCalledConditionally( + node.condition as BinaryExpression); + final relevantFnExpr = + // This cast is safe due to the type checks within `_getPropFunctionExpressionBeingCalledConditionally`. + relevantFnExprStatement?.expression as FunctionExpressionInvocation?; + if (relevantFnExpr == null) return; + // This cast is safe due to the type checks within `_getPropFunctionExpressionBeingCalledConditionally`. + final fn = relevantFnExpr.function as PropertyAccess?; + if (fn == null) return; + + yieldPatch( + '${fn.target}.${fn.propertyName}?.call${relevantFnExpr.argumentList};', + node.offset, + node.end); + } + + /// Returns the function expression (e.g. `props.onClick(event)`) being called + /// after the null condition is checked. + ExpressionStatement? _getPropFunctionExpressionBeingCalledConditionally( + BinaryExpression condition) { + final parent = condition.parent; + if (parent is! IfStatement) return null; + + final propFunctionBeingNullChecked = + _getPropFunctionBeingNullChecked(condition); + final ifStatement = parent; + if (ifStatement.elseStatement != null) return null; + if (ifStatement.parent?.tryCast()?.elseStatement == + ifStatement) { + // ifStatement is an else-if + return null; + } + final thenStatement = ifStatement.thenStatement; + if (thenStatement is Block && thenStatement.statements.length == 1) { + if (_isMatchingConditionalPropFunctionCallStatement( + thenStatement.statements.single, propFunctionBeingNullChecked)) { + return thenStatement.statements.single as ExpressionStatement?; + } + } else if (thenStatement is ExpressionStatement) { + if (_isMatchingConditionalPropFunctionCallStatement( + thenStatement, propFunctionBeingNullChecked)) { + return thenStatement; + } + } + return null; + } + + bool _isMatchingConditionalPropFunctionCallStatement( + Statement statementWithinThenStatement, + SimpleIdentifier? propFunctionBeingNullChecked) { + if (statementWithinThenStatement is! ExpressionStatement) return false; + final expression = statementWithinThenStatement.expression; + if (expression is! FunctionExpressionInvocation) return false; + final fn = expression.function; + if (fn is! PropertyAccess) return false; + final target = fn.target; + if (target is! SimpleIdentifier) return false; + if (target.name != 'props') return false; + return fn.propertyName.staticElement?.declaration == + propFunctionBeingNullChecked?.staticElement?.declaration; + } + + /// Returns the identifier for the function that is being + /// null checked before being called. + SimpleIdentifier? _getPropFunctionBeingNullChecked( + BinaryExpression condition) { + if (condition.leftOperand is! PrefixedIdentifier) { + return null; + } + final leftOperand = condition.leftOperand as PrefixedIdentifier; + final prefix = leftOperand.prefix; + if (prefix.name != 'props') { + return null; + } + if (leftOperand.identifier.staticType is! FunctionType) { + return null; + } + if (condition.operator.stringValue != '!=' && + condition.operator.next?.keyword != Keyword.NULL) { + return null; + } + return leftOperand.identifier; + } + + @override + Future generatePatches() async { + _result = await context.getResolvedUnit(); + if (_result == null) { + throw Exception( + 'Could not get resolved result for "${context.relativePath}"'); + } + _result!.unit.accept(this); + } +} diff --git a/lib/src/dart3_suggestors/null_safety_prep/utils/class_component_required_fields.dart b/lib/src/dart3_suggestors/null_safety_prep/utils/class_component_required_fields.dart new file mode 100644 index 00000000..0dd7176c --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/utils/class_component_required_fields.dart @@ -0,0 +1,193 @@ +// 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/analysis/results.dart'; +import 'package:analyzer/dart/ast/token.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/hint_detection.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'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import '../../../util/class_suggestor.dart'; +import '../analyzer_plugin_utils.dart'; + +/// A class shared by the suggestors that manage defaultProps/initialState. +abstract class ClassComponentRequiredFieldsMigrator< + Assignment extends PropOrStateAssignment> + extends RecursiveAstVisitor with ClassSuggestor { + final String relevantGetterName; + final String relevantMethodName; + + /// When set to a version that opts-in to Dart's null safety feature, + /// the `late` / `?` type modifiers will be actual modifiers rather + /// than commented hints. This should only be done using an explicit opt-in + /// flag from the executable as most consumers that have migrated to null-safety + /// will have already run this script prior to the null safety migration and thus + /// the `/*late*/` / `/*?*/` hints will already be converted to actual modifiers. + final Version? sdkVersion; + + ClassComponentRequiredFieldsMigrator( + this.relevantGetterName, this.relevantMethodName, + [this.sdkVersion]); + + late ResolvedUnitResult result; + final Set fieldData = {}; + + void patchFieldDeclarations( + List Function(InterfaceElement) getAll, + Iterable cascadedDefaultPropsOrInitialState, + CascadeExpression node) { + for (final field in cascadedDefaultPropsOrInitialState) { + final isDefaultedToNull = + field.node.rightHandSide.staticType!.isDartCoreNull; + final fieldEl = (field.node.writeElement! as PropertyAccessorElement) + .variable as FieldElement; + final propsOrStateElement = + node.staticType?.typeOrBound.tryCast()?.element; + if (propsOrStateElement == null) continue; + final fieldDeclaration = _getFieldDeclaration(getAll, + propsOrStateElement: propsOrStateElement, fieldName: fieldEl.name); + // The field declaration is likely in another file which our logic currently doesn't handle. + // In this case, don't add an entry to `fieldData`. + if (fieldDeclaration == null) continue; + + fieldData.add(DefaultedOrInitializedDeclaration( + fieldDeclaration, fieldEl, isDefaultedToNull)); + } + + fieldData.where((data) => !data.patchedDeclaration).forEach((data) { + data.patch(yieldPatch, sdkVersion: sdkVersion); + }); + } + + VariableDeclaration? _getFieldDeclaration( + List Function(InterfaceElement) getAll, + {required InterfaceElement propsOrStateElement, + required String fieldName}) { + // For component1 boilerplate its possible that `fieldEl` won't be found using `lookUpVariable` below + // since its `enclosingElement` will be the generated abstract mixin. So we'll use the provided `getAll` fn to + // cross reference the return value with the `fieldName`to locate the actual prop/state field declaration we want to patch. + final siblingFields = getAll(propsOrStateElement); + final matchingField = + siblingFields.singleWhereOrNull((element) => element.name == fieldName); + if (matchingField == null) return null; + + // NOTE: result.unit will only work if the declaration of the field is in this file + return lookUpVariable(matchingField, result.unit); + } + + @override + Future generatePatches() async { + // Clear so we don't share state across CompilationUnits + fieldData.clear(); + final r = await context.getResolvedUnit(); + if (r == null) { + throw Exception( + 'Could not get resolved result for "${context.relativePath}"'); + } + result = r; + r.unit.accept(this); + } +} + +class DefaultedOrInitializedDeclaration { + final VariableDeclaration fieldDecl; + final FieldElement fieldEl; + final bool isDefaultedToNull; + final String name; + + DefaultedOrInitializedDeclaration( + this.fieldDecl, this.fieldEl, this.isDefaultedToNull) + : _patchedDeclaration = false, + name = '${fieldDecl.name.lexeme}'; + + /// Whether the declaration has been patched with the late / nullable hints. + bool get patchedDeclaration => _patchedDeclaration; + bool _patchedDeclaration; + + void patch( + void Function(String updatedText, int startOffset, [int? endOffset]) + handleYieldPatch, + {Version? sdkVersion}) { + final parent = fieldDecl.parent! as VariableDeclarationList; + final keyword = parent.keyword; // e.g. var + final type = parent.type; + final fieldNameToken = fieldDecl.name; + if (type != null && + requiredHintAlreadyExists(type) && + (nullableHintAlreadyExists(type) || + nonNullableHintAlreadyExists(type))) { + // Short circuit - it has already been patched + _patchedDeclaration = true; + return; + } + + String? late = + type != null && requiredHintAlreadyExists(type) ? null : '/*late*/'; + String nullability = ''; + if (isDefaultedToNull) { + if (type == null || !nullableHintAlreadyExists(type)) { + nullability = nullableHint; + } + } else { + if (type == null || !nonNullableHintAlreadyExists(type)) { + nullability = nonNullableHint; + } + } + + if (sdkVersion != null && + VersionRange(min: Version.parse('2.12.0')).allows(sdkVersion)) { + if (late != null) { + // If the repo has opted into null safety, patch with the real thing instead of hints + late = 'late'; + // Unless it already has the late keyword applied + if ((type?.parent as VariableDeclarationList?)?.lateKeyword is Token) { + late = null; + } + } + + nullability = isDefaultedToNull ? '?' : ''; + + if (late == null && nullability.isEmpty) { + // Short circuit - it has already been patched + _patchedDeclaration = true; + return; + } + } + + late = late ?? ''; + // dynamic added if type is null b/c we gotta have a type to add the nullable `?`/`!` hints to - even if for some reason the prop/state decl. has no left side type. + final patchedType = + type == null ? 'dynamic$nullability' : '${type.toSource()}$nullability'; + final startOffset = + type?.offset ?? keyword?.offset ?? fieldNameToken.offset; + handleYieldPatch('$late $patchedType ', startOffset, fieldNameToken.offset); + + _patchedDeclaration = true; + } + + @override + bool operator ==(Object other) { + return other is DefaultedOrInitializedDeclaration && + other.fieldEl == this.fieldEl; + } + + @override + int get hashCode => this.fieldEl.hashCode; +} diff --git a/lib/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart b/lib/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart new file mode 100644 index 00000000..f8a82a0e --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart @@ -0,0 +1,42 @@ +// 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:over_react_codemod/src/util.dart'; + +/// Whether the nullability hint already exists after [type]. +bool nullableHintAlreadyExists(TypeAnnotation type) { + // The nullability hint will follow the type so we need to check the next token to find the comment if it exists. + final commentsPrecedingType = type.endToken.next?.precedingComments?.value(); + return commentsPrecedingType?.contains(nullableHint) ?? false; +} + +const nullableHint = '/*?*/'; + +/// Whether the non-nullable hint already exists after [type]. +bool nonNullableHintAlreadyExists(TypeAnnotation type) { + // The nullability hint will follow the type so we need to check the next token to find the comment if it exists. + final commentsPrecedingType = type.endToken.next?.precedingComments?.value(); + return commentsPrecedingType?.contains(nonNullableHint) ?? false; +} + +const nonNullableHint = '/*!*/'; + +/// Whether the late hint already exists before [type] +bool requiredHintAlreadyExists(TypeAnnotation type) { + // Since the `/*late*/` comment is possibly adjacent to the prop declaration's doc comments, + // we have to recursively traverse the `precedingComments` in order to determine if the `/*late*/` + // comment actually exists. + return allComments(type.beginToken).any((t) => t.value() == '/*late*/'); +} diff --git a/lib/src/executables/null_safety_migrator_companion.dart b/lib/src/executables/null_safety_migrator_companion.dart new file mode 100644 index 00000000..87567ab0 --- /dev/null +++ b/lib/src/executables/null_safety_migrator_companion.dart @@ -0,0 +1,54 @@ +// 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 '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_default_props.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart'; +import 'package:over_react_codemod/src/util.dart'; + +import '../dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.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:null_safety_prep +"""; + +/// Codemods in this executable add nullability "hints" to assist with a +/// null-safety migration. +/// +/// If it has not already been run, the `null_safety_prep` codemod should +/// also be run when migrating to null-safety. +void main(List args) async { + final parser = ArgParser.allowAnything(); + + final parsedArgs = parser.parse(args); + final dartPaths = allDartPathsExceptHiddenAndGenerated(); + + exitCode = await runInteractiveCodemod( + dartPaths, + aggregate([ + CallbackRefHintSuggestor(), + ClassComponentRequiredDefaultPropsMigrator(), + ClassComponentRequiredInitialStateMigrator(), + ]), + defaultYes: true, + args: parsedArgs.rest, + additionalHelpOutput: parser.usage, + changesRequiredOutput: _changesRequiredOutput, + ); +} diff --git a/lib/src/executables/null_safety_prep.dart b/lib/src/executables/null_safety_prep.dart index f9eb42ee..5f28615d 100644 --- a/lib/src/executables/null_safety_prep.dart +++ b/lib/src/executables/null_safety_prep.dart @@ -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/dom_callback_null_args.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/fn_prop_null_aware_call_suggestor.dart'; import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/use_ref_init_migration.dart'; import 'package:over_react_codemod/src/util.dart'; @@ -28,6 +29,12 @@ const _changesRequiredOutput = """ pub global run over_react_codemod:null_safety_prep """; +/// Codemods in this executable should be changes that teams +/// can make ahead of moving forward with their null-safety migration. +/// +/// Codemods that do things like add nullability "hints" should be placed +/// within `null_safety_migrator_companion` - and run only when a team is +/// ready to move forward with a null-safety migration. void main(List args) async { final parser = ArgParser.allowAnything(); @@ -38,8 +45,8 @@ void main(List args) async { dartPaths, aggregate([ UseRefInitMigration(), + FnPropNullAwareCallSuggestor(), DomCallbackNullArgs(), - CallbackRefHintSuggestor(), ]), defaultYes: true, args: parsedArgs.rest, diff --git a/lib/src/util.dart b/lib/src/util.dart index 9d1a0dbb..5fcb41fb 100644 --- a/lib/src/util.dart +++ b/lib/src/util.dart @@ -538,6 +538,15 @@ extension FileContextSourceHelper on FileContext { sourceText.substring(entity.offset, entity.end); } +extension ParentFieldDeclExtension on VariableDeclaration { + FieldDeclaration? get parentFieldDeclaration { + final field = thisOrAncestorOfType(); + return field != null && field.fields.variables.contains(this) + ? field + : null; + } +} + String blockComment(String contents) => '/*$contents*/'; String lineComment(String contents) => diff --git a/lib/src/util/component_usage.dart b/lib/src/util/component_usage.dart index 5af35063..0a1f4c6f 100644 --- a/lib/src/util/component_usage.dart +++ b/lib/src/util/component_usage.dart @@ -382,22 +382,7 @@ class BuilderMethodInvocation extends BuilderMemberAccess { Identifier get methodName => node.methodName; } -/// An assignment of a property (usually a prop) on a [FluentComponentUsage] builder. -abstract class PropAssignment extends BuilderMemberAccess { - factory PropAssignment(AssignmentExpression node) { - if (node.leftHandSide is PropertyAccess) { - return _PropertyAccessPropAssignment(node); - } - - throw ArgumentError.value( - node.leftHandSide, - 'node.leftHandSide', - 'Unhandled LHS node type', - ); - } - - PropAssignment._(); - +mixin PropOrStateAssignment on BuilderMemberAccess { /// The name of the prop being assigned, /// or of the property on a prop being assigned. /// @@ -443,6 +428,65 @@ abstract class PropAssignment extends BuilderMemberAccess { Expression get rightHandSide => node.rightHandSide; } +abstract class StateAssignment extends BuilderMemberAccess + with PropOrStateAssignment { + factory StateAssignment(AssignmentExpression node) { + if (node.leftHandSide is PropertyAccess) { + return _PropertyAccessStateAssignment(node); + } + + throw ArgumentError.value( + node.leftHandSide, + 'node.leftHandSide', + 'Unhandled LHS node type', + ); + } + + StateAssignment._(); +} + +class _PropertyAccessStateAssignment extends StateAssignment { + /// The cascaded assignment expression that backs this assignment. + @override + final AssignmentExpression node; + + _PropertyAccessStateAssignment(this.node) + : assert(node.leftHandSide is PropertyAccess), + super._(); + + /// The property access representing the left hand side of this assignment. + @override + PropertyAccess get leftHandSide => node.leftHandSide as PropertyAccess; + + @override + SimpleIdentifier get name => leftHandSide.propertyName; + + @override + Expression get target => leftHandSide.realTarget; + + @override + SimpleIdentifier? get prefix => + leftHandSide.target?.tryCast()?.propertyName; +} + +/// An assignment of a property (usually a prop) on a [FluentComponentUsage] builder. +abstract class PropAssignment extends BuilderMemberAccess + with PropOrStateAssignment { + factory PropAssignment(AssignmentExpression node) { + if (node.leftHandSide is PropertyAccess) { + return _PropertyAccessPropAssignment(node); + } + + throw ArgumentError.value( + node.leftHandSide, + 'node.leftHandSide', + 'Unhandled LHS node type', + ); + } + + PropAssignment._(); +} + class _PropertyAccessPropAssignment extends PropAssignment { /// The cascaded assignment expression that backs this assignment. @override diff --git a/lib/src/util/get_all_props.dart b/lib/src/util/get_all_props.dart new file mode 100644 index 00000000..80ee8df2 --- /dev/null +++ b/lib/src/util/get_all_props.dart @@ -0,0 +1,129 @@ +// Taken from https://github.com/Workiva/over_react/blob/master/tools/analyzer_plugin/lib/src/util/prop_declarations/get_all_props.dart + +// 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/element/element.dart'; +import 'package:collection/collection.dart'; + +// Performance optimization notes: +// [1] Building a list here is slightly more optimal than using a generator. +// +// [2] Ideally we'd check the library and package here, +// but for some reason accessing `.library` is pretty inefficient. +// Possibly due to the `LibraryElementImpl? get library => thisOrAncestorOfType();` impl, +// and an inefficient `thisOrAncestorOfType` impl: https://github.com/dart-lang/sdk/issues/53255 + +/// Returns all props defined in a props class/mixin [propsElement] as well as all of its supertypes, +/// except for those shared by all UiProps instances by default (e.g., ReactPropsMixin, UbiquitousDomPropsMixin, +/// and CssClassPropsMixin's key, ref, children, className, id, onClick, etc.). +/// +/// Each returned field will be the consumer-declared prop field, and the list will not contain overrides +/// from generated over_react parts. +/// +/// Excludes any fields annotated with `@doNotGenerate`. +List getAllProps(InterfaceElement propsElement) { + final propsAndSupertypeElements = propsElement.thisAndSupertypesList; + + // There are two UiProps; one in component_base, and one in builder_helpers that extends from it. + // Use the component_base one, since there are some edge-cases of props that don't extend from the + // builder_helpers version. + final uiPropsElement = propsAndSupertypeElements.firstWhereOrNull((i) => + i.name == 'UiProps' && + i.library.name == 'over_react.component_declaration.component_base'); + + // If propsElement does not inherit from from UiProps, it could still be a legacy mixin that doesn't implement UiProps. + // This check is only necessary to retrieve props when [propsElement] is itself a legacy mixin, and not when legacy + // props mixins are encountered below as supertypes. + final inheritsFromUiProps = uiPropsElement != null; + late final isPropsMixin = propsElement.metadata.any(_isPropsMixinAnnotation); + if (!inheritsFromUiProps && !isPropsMixin) { + return []; + } + + final uiPropsAndSupertypeElements = uiPropsElement?.thisAndSupertypesSet; + + final allProps = []; // [1] + for (final interface in propsAndSupertypeElements) { + // Don't process UiProps or its supertypes + // (Object, Map, MapBase, MapViewMixin, PropsMapViewMixin, ReactPropsMixin, UbiquitousDomPropsMixin, CssClassPropsMixin, ...) + if (uiPropsAndSupertypeElements?.contains(interface) ?? false) continue; + + // Filter out generated accessors mixins for legacy concrete props classes. + late final isFromGeneratedFile = + interface.source.uri.path.endsWith('.over_react.g.dart'); + if (interface.name.endsWith('AccessorsMixin') && isFromGeneratedFile) { + continue; + } + + final isMixinBasedPropsMixin = interface is MixinElement && + interface.superclassConstraints.any((s) => s.element.name == 'UiProps'); + late final isLegacyPropsOrPropsMixinConsumerClass = !isFromGeneratedFile && + interface.metadata.any(_isPropsOrPropsMixinAnnotation); + + if (!isMixinBasedPropsMixin && !isLegacyPropsOrPropsMixinConsumerClass) { + continue; + } + + for (final field in interface.fields) { + if (field.isStatic) continue; + if (field.isSynthetic) continue; + + final accessorAnnotation = _getAccessorAnnotation(field.metadata); + final isNoGenerate = accessorAnnotation + ?.computeConstantValue() + ?.getField('doNotGenerate') + ?.toBoolValue() ?? + false; + if (isNoGenerate) continue; + + allProps.add(field); + } + } + + return allProps; +} + +bool _isPropsOrPropsMixinAnnotation(ElementAnnotation e) { + // [2] + final element = e.element; + return element is ConstructorElement && + const {'Props', 'PropsMixin'}.contains(element.enclosingElement.name); +} + +bool _isPropsMixinAnnotation(ElementAnnotation e) { + // [2] + final element = e.element; + return element is ConstructorElement && + element.enclosingElement.name == 'PropsMixin'; +} + +ElementAnnotation? _getAccessorAnnotation(List metadata) { + return metadata.firstWhereOrNull((annotation) { + // [2] + final element = annotation.element; + return element is ConstructorElement && + element.enclosingElement.name == 'Accessor'; + }); +} + +extension on InterfaceElement { + // Two separate collection implementations to micro-optimize collection creation/iteration based on usage. + + Set get thisAndSupertypesSet => + {this, for (final s in allSupertypes) s.element}; + + List get thisAndSupertypesList => + [this, for (final s in allSupertypes) s.element]; +} diff --git a/lib/src/util/get_all_state.dart b/lib/src/util/get_all_state.dart new file mode 100644 index 00000000..51449ebc --- /dev/null +++ b/lib/src/util/get_all_state.dart @@ -0,0 +1,127 @@ +// Adapted from https://github.com/Workiva/over_react/blob/master/tools/analyzer_plugin/lib/src/util/prop_declarations/get_all_props.dart + +// 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/element/element.dart'; +import 'package:collection/collection.dart'; + +// Performance optimization notes: +// [1] Building a list here is slightly more optimal than using a generator. +// +// [2] Ideally we'd check the library and package here, +// but for some reason accessing `.library` is pretty inefficient. +// Possibly due to the `LibraryElementImpl? get library => thisOrAncestorOfType();` impl, +// and an inefficient `thisOrAncestorOfType` impl: https://github.com/dart-lang/sdk/issues/53255 + +/// Returns all state defined in a props class/mixin [stateElement] as well as all of its supertypes, +/// except for those shared by all UiState instances by default. +/// +/// Each returned field will be the consumer-declared state field, and the list will not contain overrides +/// from generated over_react parts. +/// +/// Excludes any fields annotated with `@doNotGenerate`. +List getAllState(InterfaceElement stateElement) { + final stateAndSupertypeElements = stateElement.thisAndSupertypesList; + + // There are two UiState; one in component_base, and one in builder_helpers that extends from it. + // Use the component_base one, since there are some edge-cases of props that don't extend from the + // builder_helpers version. + final uiStateElement = stateAndSupertypeElements.firstWhereOrNull((i) => + i.name == 'UiState' && + i.library.name == 'over_react.component_declaration.component_base'); + + // If stateElement does not inherit from from UiState, it could still be a legacy mixin that doesn't implement UiState. + // This check is only necessary to retrieve props when [stateElement] is itself a legacy mixin, and not when legacy + // props mixins are encountered below as supertypes. + final inheritsFromUiState = uiStateElement != null; + late final isStateMixin = stateElement.metadata.any(_isStateMixinAnnotation); + if (!inheritsFromUiState && !isStateMixin) { + return []; + } + + final uiStateAndSupertypeElements = uiStateElement?.thisAndSupertypesSet; + + final allState = []; // [1] + for (final interface in stateAndSupertypeElements) { + // Don't process UiState or its supertypes + if (uiStateAndSupertypeElements?.contains(interface) ?? false) continue; + + // Filter out generated accessors mixins for legacy concrete props classes. + late final isFromGeneratedFile = + interface.source.uri.path.endsWith('.over_react.g.dart'); + if (interface.name.endsWith('AccessorsMixin') && isFromGeneratedFile) { + continue; + } + + final isMixinBasedPropsMixin = interface is MixinElement && + interface.superclassConstraints.any((s) => s.element.name == 'UiState'); + late final isLegacyStateOrStateMixinConsumerClass = !isFromGeneratedFile && + interface.metadata.any(_isStateOrStateMixinAnnotation); + + if (!isMixinBasedPropsMixin && !isLegacyStateOrStateMixinConsumerClass) { + continue; + } + + for (final field in interface.fields) { + if (field.isStatic) continue; + if (field.isSynthetic) continue; + + final accessorAnnotation = _getAccessorAnnotation(field.metadata); + final isNoGenerate = accessorAnnotation + ?.computeConstantValue() + ?.getField('doNotGenerate') + ?.toBoolValue() ?? + false; + if (isNoGenerate) continue; + + allState.add(field); + } + } + + return allState; +} + +bool _isStateOrStateMixinAnnotation(ElementAnnotation e) { + // [2] + final element = e.element; + return element is ConstructorElement && + const {'State', 'StateMixin'}.contains(element.enclosingElement.name); +} + +bool _isStateMixinAnnotation(ElementAnnotation e) { + // [2] + final element = e.element; + return element is ConstructorElement && + element.enclosingElement.name == 'StateMixin'; +} + +ElementAnnotation? _getAccessorAnnotation(List metadata) { + return metadata.firstWhereOrNull((annotation) { + // [2] + final element = annotation.element; + return element is ConstructorElement && + element.enclosingElement.name == 'Accessor'; + }); +} + +extension on InterfaceElement { + // Two separate collection implementations to micro-optimize collection creation/iteration based on usage. + + Set get thisAndSupertypesSet => + {this, for (final s in allSupertypes) s.element}; + + List get thisAndSupertypesList => + [this, for (final s in allSupertypes) s.element]; +} diff --git a/pubspec.yaml b/pubspec.yaml index 696c061f..d3550c91 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: over_react_codemod -version: 2.30.0 +version: 2.31.0 homepage: https://github.com/Workiva/over_react_codemod description: > @@ -41,6 +41,7 @@ dev_dependencies: executables: dart2_9_upgrade: dependency_validator_ignore: + null_safety_migrator_companion: null_safety_prep: mui_migration: required_flux_props: diff --git a/skynet.yaml b/skynet.yaml deleted file mode 100644 index 66852ea5..00000000 --- a/skynet.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: private-unit-tests -description: Unit tests that require access to our private Pub server. -contact: 'Frontend Design / #support-ui-platform' -image: drydock.workiva.net/workiva/dart_unit_test_image:2 -size: large -timeout: eternal - -artifacts: /testing/test-reports -test-reports: /testing/test-reports - -scripts: - - dart pub get - - RESULT=0 - - echo 'Running dart pub get in test fixtures beforehand to prevent concurrent `dart pub get`s in tests from failing' - - (cd test/test_fixtures/over_react_project && dart pub get) - - (cd test/test_fixtures/wsd_project && dart pub get) - - (cd test/test_fixtures/rmui_project && dart pub get) - - echo 'Running only the tests with the "wsd" tag' - # TODO think about using an aggregated test suite for these so that we can reuse the same SharedAnalysisContext/AnalysisContextCollection instances and run tests faster. - - dart test --tags=wsd --file-reporter=json:test-reports/wsd.json || RESULT=1 - - dart pub global run w_test_tools:xunit_parser -j test-reports/wsd.json -t test-reports/wsd.xml - - exit $RESULT diff --git a/test/dart3_suggestors/null_safety_prep/class_component_required_default_props_test.dart b/test/dart3_suggestors/null_safety_prep/class_component_required_default_props_test.dart new file mode 100644 index 00000000..ddfc1b1c --- /dev/null +++ b/test/dart3_suggestors/null_safety_prep/class_component_required_default_props_test.dart @@ -0,0 +1,586 @@ +// 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:over_react_codemod/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import '../../resolved_file_context.dart'; +import '../../util.dart'; +import '../../util/component_usage_migrator_test.dart' show withOverReactImport; + +void main() { + final resolvedContext = SharedAnalysisContext.overReact; + + // Warm up analysis in a setUpAll so that if getting the resolved AST times out + // (which is more common for the WSD context), it fails here instead of failing the first test. + setUpAll(resolvedContext.warmUpAnalysis); + + group('ClassComponentRequiredDefaultPropsMigrator', () { + late SuggestorTester testSuggestor; + + group('when sdkVersion is not set', () { + setUp(() { + testSuggestor = getSuggestorTester( + ClassComponentRequiredDefaultPropsMigrator(), + resolvedContext: resolvedContext, + ); + }); + + test('patches defaulted props in mixins', () async { + await testSuggestor( + expectedPatchCount: 7, + input: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooPropsMixin on UiProps { + String notDefaulted; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*!*/ alreadyPatchedButNoDocComment; + String defaultedNullable; + num defaultedNonNullable; + var untypedDefaultedNonNullable; + var untypedDefaultedNullable; + var untypedNotDefaulted; + } + mixin SomeOtherPropsMixin on UiProps { + num anotherDefaultedNonNullable; + Function defaultedNonNullableFn; + List defaultedNonNullableList; + } + class FooProps = UiProps with FooPropsMixin, SomeOtherPropsMixin; + class FooComponent extends UiComponent2 { + @override + get defaultProps => (newProps() + ..alreadyPatched = 'foo' + ..untypedDefaultedNonNullable = 1 + ..untypedDefaultedNullable = null + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ..defaultedNonNullableFn = () {} + ..defaultedNonNullableList = [] + ); + + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooPropsMixin on UiProps { + String notDefaulted; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*!*/ alreadyPatchedButNoDocComment; + /*late*/ String/*?*/ defaultedNullable; + /*late*/ num/*!*/ defaultedNonNullable; + /*late*/ dynamic/*!*/ untypedDefaultedNonNullable; + /*late*/ dynamic/*?*/ untypedDefaultedNullable; + var untypedNotDefaulted; + } + mixin SomeOtherPropsMixin on UiProps { + /*late*/ num/*!*/ anotherDefaultedNonNullable; + /*late*/ Function/*!*/ defaultedNonNullableFn; + /*late*/ List/*!*/ defaultedNonNullableList; + } + class FooProps = UiProps with FooPropsMixin, SomeOtherPropsMixin; + class FooComponent extends UiComponent2 { + @override + get defaultProps => (newProps() + ..alreadyPatched = 'foo' + ..untypedDefaultedNonNullable = 1 + ..untypedDefaultedNullable = null + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ..defaultedNonNullableFn = () {} + ..defaultedNonNullableList = [] + ); + + @override + render() => null; + } + '''), + ); + }); + + test( + 'patches defaulted props in mixins when defaults are in the props mixin', + () async { + await testSuggestor( + expectedPatchCount: 5, + input: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooPropsMixin on UiProps { + static final defaultProps = Foo() + ..alreadyPatched = 'foo' + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ..defaultedNonNullableFn = () {} + ..defaultedNonNullableList = []; + + String notDefaulted; + /*late*/ String/*!*/ alreadyPatched; + String defaultedNullable; + num defaultedNonNullable; + } + mixin SomeOtherPropsMixin on UiProps { + num anotherDefaultedNonNullable; + Function defaultedNonNullableFn; + List defaultedNonNullableList; + } + class FooProps = UiProps with FooPropsMixin, SomeOtherPropsMixin; + class FooComponent extends UiComponent2 { + @override + get defaultProps => FooPropsMixin.defaultProps; + + @override + render() => null; + } + + @Factory() + UiFactory FooLegacy = _$FooLegacy; // ignore: undefined_identifier + @Component() + class FooLegacyComponent extends UiComponent { + @override + getDefaultProps() => FooPropsMixin.defaultProps; + + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooPropsMixin on UiProps { + static final defaultProps = Foo() + ..alreadyPatched = 'foo' + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ..defaultedNonNullableFn = () {} + ..defaultedNonNullableList = []; + + String notDefaulted; + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*?*/ defaultedNullable; + /*late*/ num/*!*/ defaultedNonNullable; + } + mixin SomeOtherPropsMixin on UiProps { + /*late*/ num/*!*/ anotherDefaultedNonNullable; + /*late*/ Function/*!*/ defaultedNonNullableFn; + /*late*/ List/*!*/ defaultedNonNullableList; + } + class FooProps = UiProps with FooPropsMixin, SomeOtherPropsMixin; + class FooComponent extends UiComponent2 { + @override + get defaultProps => FooPropsMixin.defaultProps; + + @override + render() => null; + } + + @Factory() + UiFactory FooLegacy = _$FooLegacy; // ignore: undefined_identifier + @Component() + class FooLegacyComponent extends UiComponent { + @override + getDefaultProps() => FooPropsMixin.defaultProps; + + @override + render() => null; + } + '''), + ); + }); + + test('patches defaulted props in abstract classes', () async { + await testSuggestor( + expectedPatchCount: 7, + input: withOverReactImport(/*language=dart*/ r''' + mixin FooPropsMixin on UiProps { + String notDefaulted; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*!*/ alreadyPatchedButNoDocComment; + String defaultedNullable; + num defaultedNonNullable; + var untypedDefaultedNonNullable; + var untypedDefaultedNullable; + var untypedNotDefaulted; + } + mixin SomeOtherPropsMixin on UiProps { + num anotherDefaultedNonNullable; + Function defaultedNonNullableFn; + List defaultedNonNullableList; + } + class FooProps = UiProps with FooPropsMixin, SomeOtherPropsMixin; + abstract class FooComponent extends UiComponent2 { + @override + get defaultProps => (newProps() + ..alreadyPatched = 'foo' + ..untypedDefaultedNonNullable = 1 + ..untypedDefaultedNullable = null + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ..defaultedNonNullableFn = () {} + ..defaultedNonNullableList = [] + ); + + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + mixin FooPropsMixin on UiProps { + String notDefaulted; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*!*/ alreadyPatchedButNoDocComment; + /*late*/ String/*?*/ defaultedNullable; + /*late*/ num/*!*/ defaultedNonNullable; + /*late*/ dynamic/*!*/ untypedDefaultedNonNullable; + /*late*/ dynamic/*?*/ untypedDefaultedNullable; + var untypedNotDefaulted; + } + mixin SomeOtherPropsMixin on UiProps { + /*late*/ num/*!*/ anotherDefaultedNonNullable; + /*late*/ Function/*!*/ defaultedNonNullableFn; + /*late*/ List/*!*/ defaultedNonNullableList; + } + class FooProps = UiProps with FooPropsMixin, SomeOtherPropsMixin; + abstract class FooComponent extends UiComponent2 { + @override + get defaultProps => (newProps() + ..alreadyPatched = 'foo' + ..untypedDefaultedNonNullable = 1 + ..untypedDefaultedNullable = null + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ..defaultedNonNullableFn = () {} + ..defaultedNonNullableList = [] + ); + + @override + render() => null; + } + '''), + ); + }); + + test('patches defaulted props in legacy classes', () async { + await testSuggestor( + expectedPatchCount: 3, + input: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @PropsMixin() + mixin SomeOtherPropsMixin on UiProps { + num anotherDefaultedNonNullable; + } + @Props() + class FooProps extends UiProps with SomeOtherPropsMixin { + String notDefaulted; + String defaultedNullable; + num defaultedNonNullable; + } + @Component() + class FooComponent extends UiComponent { + @override + getDefaultProps() => (newProps() + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ); + + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @PropsMixin() + mixin SomeOtherPropsMixin on UiProps { + /*late*/ num/*!*/ anotherDefaultedNonNullable; + } + @Props() + class FooProps extends UiProps with SomeOtherPropsMixin { + String notDefaulted; + /*late*/ String/*?*/ defaultedNullable; + /*late*/ num/*!*/ defaultedNonNullable; + } + @Component() + class FooComponent extends UiComponent { + @override + getDefaultProps() => (newProps() + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ); + + @override + render() => null; + } + '''), + ); + }); + + test( + 'patches defaulted props in legacy classes using component1 boilerplate', + () async { + await testSuggestor( + expectedPatchCount: 2, + input: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @Props() + class _$FooProps extends UiProps { + String notDefaulted; + String defaultedNullable; + num defaultedNonNullable; + } + @Component() + class FooComponent extends UiComponent { + @override + getDefaultProps() => (newProps() + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ); + + @override + render() => null; + } + class FooProps extends _$FooProps + with + // ignore: mixin_of_non_class, undefined_class + _$FooPropsAccessorsMixin { + // ignore: const_initialized_with_non_constant_value, undefined_class, undefined_identifier + static const PropsMeta meta = _$metaForFooProps; + } + abstract class _$FooPropsAccessorsMixin implements _$FooProps { + set defaultedNullable(val) {} + get defaultedNullable => ''; + set defaultedNonNullable(val) {} + get defaultedNonNullable => 1; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @Props() + class _$FooProps extends UiProps { + String notDefaulted; + /*late*/ String/*?*/ defaultedNullable; + /*late*/ num/*!*/ defaultedNonNullable; + } + @Component() + class FooComponent extends UiComponent { + @override + getDefaultProps() => (newProps() + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ); + + @override + render() => null; + } + class FooProps extends _$FooProps + with + // ignore: mixin_of_non_class, undefined_class + _$FooPropsAccessorsMixin { + // ignore: const_initialized_with_non_constant_value, undefined_class, undefined_identifier + static const PropsMeta meta = _$metaForFooProps; + } + abstract class _$FooPropsAccessorsMixin implements _$FooProps { + set defaultedNullable(val) {} + get defaultedNullable => ''; + set defaultedNonNullable(val) {} + get defaultedNonNullable => 1; + } + '''), + ); + }); + }); + + group('when sdkVersion is set to 2.19.6', () { + setUp(() { + testSuggestor = getSuggestorTester( + ClassComponentRequiredDefaultPropsMigrator(Version.parse('2.19.6')), + resolvedContext: resolvedContext, + ); + }); + + test('patches defaulted props in mixins', () async { + await testSuggestor( + isExpectedError: (err) { + return err.message.contains(RegExp(r"Unexpected text 'late'")); + }, + expectedPatchCount: 3, + input: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooPropsMixin on UiProps { + /// This is a doc comment + late String alreadyPatched; + String notDefaulted; + String defaultedNullable; + num defaultedNonNullable; + } + mixin SomeOtherPropsMixin on UiProps { + num anotherDefaultedNonNullable; + } + class FooProps = UiProps with FooPropsMixin, SomeOtherPropsMixin; + class FooComponent extends UiComponent2 { + @override + get defaultProps => (newProps() + ..alreadyPatched = 'foo' + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ); + + @override + render() => null; + } + + @Factory() + UiFactory FooLegacy = _$FooLegacy; // ignore: undefined_identifier + @Component() + class FooLegacyComponent extends UiComponent { + @override + getDefaultProps() => (newProps() + ..alreadyPatched = 'foo' + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ); + + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooPropsMixin on UiProps { + /// This is a doc comment + late String alreadyPatched; + String notDefaulted; + late String? defaultedNullable; + late num defaultedNonNullable; + } + mixin SomeOtherPropsMixin on UiProps { + late num anotherDefaultedNonNullable; + } + class FooProps = UiProps with FooPropsMixin, SomeOtherPropsMixin; + class FooComponent extends UiComponent2 { + @override + get defaultProps => (newProps() + ..alreadyPatched = 'foo' + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ); + + @override + render() => null; + } + + @Factory() + UiFactory FooLegacy = _$FooLegacy; // ignore: undefined_identifier + @Component() + class FooLegacyComponent extends UiComponent { + @override + getDefaultProps() => (newProps() + ..alreadyPatched = 'foo' + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ); + + @override + render() => null; + } + '''), + ); + }); + + test('patches defaulted props in legacy classes', () async { + await testSuggestor( + expectedPatchCount: 3, + input: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @PropsMixin() + mixin SomeOtherPropsMixin on UiProps { + num anotherDefaultedNonNullable; + } + @Props() + class FooProps extends UiProps with SomeOtherPropsMixin { + String notDefaulted; + String defaultedNullable; + num defaultedNonNullable; + } + @Component() + class FooComponent extends UiComponent { + @override + getDefaultProps() => (newProps() + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ); + + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @PropsMixin() + mixin SomeOtherPropsMixin on UiProps { + late num anotherDefaultedNonNullable; + } + @Props() + class FooProps extends UiProps with SomeOtherPropsMixin { + String notDefaulted; + late String? defaultedNullable; + late num defaultedNonNullable; + } + @Component() + class FooComponent extends UiComponent { + @override + getDefaultProps() => (newProps() + ..defaultedNullable = null + ..defaultedNonNullable = 2.1 + ..anotherDefaultedNonNullable = 1.1 + ); + + @override + render() => null; + } + '''), + ); + }); + }); + }); +} diff --git a/test/dart3_suggestors/null_safety_prep/class_component_required_initial_state_test.dart b/test/dart3_suggestors/null_safety_prep/class_component_required_initial_state_test.dart new file mode 100644 index 00000000..1137b152 --- /dev/null +++ b/test/dart3_suggestors/null_safety_prep/class_component_required_initial_state_test.dart @@ -0,0 +1,269 @@ +// 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:over_react_codemod/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart'; +import 'package:test/test.dart'; + +import '../../resolved_file_context.dart'; +import '../../util.dart'; +import '../../util/component_usage_migrator_test.dart' show withOverReactImport; + +void main() { + final resolvedContext = SharedAnalysisContext.overReact; + + // Warm up analysis in a setUpAll so that if getting the resolved AST times out + // (which is more common for the WSD context), it fails here instead of failing the first test. + setUpAll(resolvedContext.warmUpAnalysis); + + group('ClassComponentRequiredInitialStateMigrator', () { + late SuggestorTester testSuggestor; + + group('when sdkVersion is not set', () { + setUp(() { + testSuggestor = getSuggestorTester( + ClassComponentRequiredInitialStateMigrator(), + resolvedContext: resolvedContext, + ); + }); + + test('patches initialized state fields in mixins', () async { + await testSuggestor( + expectedPatchCount: 5, + input: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooProps on UiProps {} + mixin FooStateMixin on UiState { + String notInitialized; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*!*/ alreadyPatchedButNoDocComment; + String initializedNullable; + num initializedNonNullable; + } + mixin SomeOtherStateMixin on UiState { + num anotherInitializedNonNullable; + Function initializedNonNullableFn; + List initializedNonNullableList; + } + class FooState = UiState with FooStateMixin, SomeOtherStateMixin; + class FooComponent extends UiStatefulComponent2 { + @override + get initialState => (newState() + ..alreadyPatched = 'foo' + ..initializedNullable = null + ..initializedNonNullable = 2.1 + ..anotherInitializedNonNullable = 1.1 + ..initializedNonNullableFn = () {} + ..initializedNonNullableList = [] + ); + + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooProps on UiProps {} + mixin FooStateMixin on UiState { + String notInitialized; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*!*/ alreadyPatchedButNoDocComment; + /*late*/ String/*?*/ initializedNullable; + /*late*/ num/*!*/ initializedNonNullable; + } + mixin SomeOtherStateMixin on UiState { + /*late*/ num/*!*/ anotherInitializedNonNullable; + /*late*/ Function/*!*/ initializedNonNullableFn; + /*late*/ List/*!*/ initializedNonNullableList; + } + class FooState = UiState with FooStateMixin, SomeOtherStateMixin; + class FooComponent extends UiStatefulComponent2 { + @override + get initialState => (newState() + ..alreadyPatched = 'foo' + ..initializedNullable = null + ..initializedNonNullable = 2.1 + ..anotherInitializedNonNullable = 1.1 + ..initializedNonNullableFn = () {} + ..initializedNonNullableList = [] + ); + + @override + render() => null; + } + '''), + ); + }); + + test('patches initialized state in legacy classes', () async { + await testSuggestor( + expectedPatchCount: 3, + input: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @Props() + class FooProps extends UiProps {} + @StateMixin() + mixin SomeOtherStateMixin on UiState { + num anotherInitializedNonNullable; + } + @State() + class FooState extends UiState with SomeOtherStateMixin { + String notInitialized; + String initializedNullable; + num initializedNonNullable; + } + @Component() + class FooComponent extends UiStatefulComponent { + @override + getInitialState() => (newState() + ..initializedNullable = null + ..initializedNonNullable = 2.1 + ..anotherInitializedNonNullable = 1.1 + ); + + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @Props() + class FooProps extends UiProps {} + @StateMixin() + mixin SomeOtherStateMixin on UiState { + /*late*/ num/*!*/ anotherInitializedNonNullable; + } + @State() + class FooState extends UiState with SomeOtherStateMixin { + String notInitialized; + /*late*/ String/*?*/ initializedNullable; + /*late*/ num/*!*/ initializedNonNullable; + } + @Component() + class FooComponent extends UiStatefulComponent { + @override + getInitialState() => (newState() + ..initializedNullable = null + ..initializedNonNullable = 2.1 + ..anotherInitializedNonNullable = 1.1 + ); + + @override + render() => null; + } + '''), + ); + }); + + test( + 'patches initialized state in legacy classes using component1 boilerplate', + () async { + await testSuggestor( + expectedPatchCount: 2, + input: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @Props() + class _$FooProps extends UiProps {} + @State() + class _$FooState extends UiState { + String notInitialized; + String initializedNullable; + num initializedNonNullable; + } + @Component() + class FooComponent extends UiStatefulComponent { + @override + getInitialState() => (newState() + ..initializedNullable = null + ..initializedNonNullable = 2.1 + ); + + @override + render() => null; + } + class FooProps extends _$FooProps + with + // ignore: mixin_of_non_class, undefined_class + _$FooPropsAccessorsMixin { + // ignore: const_initialized_with_non_constant_value, undefined_class, undefined_identifier + static const PropsMeta meta = _$metaForFooProps; + } + class FooState extends _$FooState + with + // ignore: mixin_of_non_class, undefined_class + _$FooStateAccessorsMixin { + // ignore: const_initialized_with_non_constant_value, undefined_class, undefined_identifier + static const StateMeta meta = _$metaForFooState; + } + abstract class _$FooStateAccessorsMixin implements _$FooState { + set initializedNullable(val) {} + get initializedNullable => ''; + set initializedNonNullable(val) {} + get initializedNonNullable => 1; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @Props() + class _$FooProps extends UiProps {} + @State() + class _$FooState extends UiState { + String notInitialized; + /*late*/ String/*?*/ initializedNullable; + /*late*/ num/*!*/ initializedNonNullable; + } + @Component() + class FooComponent extends UiStatefulComponent { + @override + getInitialState() => (newState() + ..initializedNullable = null + ..initializedNonNullable = 2.1 + ); + + @override + render() => null; + } + class FooProps extends _$FooProps + with + // ignore: mixin_of_non_class, undefined_class + _$FooPropsAccessorsMixin { + // ignore: const_initialized_with_non_constant_value, undefined_class, undefined_identifier + static const PropsMeta meta = _$metaForFooProps; + } + class FooState extends _$FooState + with + // ignore: mixin_of_non_class, undefined_class + _$FooStateAccessorsMixin { + // ignore: const_initialized_with_non_constant_value, undefined_class, undefined_identifier + static const StateMeta meta = _$metaForFooState; + } + abstract class _$FooStateAccessorsMixin implements _$FooState { + set initializedNullable(val) {} + get initializedNullable => ''; + set initializedNonNullable(val) {} + get initializedNonNullable => 1; + } + '''), + ); + }); + }); + }); +} diff --git a/test/dart3_suggestors/null_safety_prep/fn_prop_null_aware_call_suggestor_test.dart b/test/dart3_suggestors/null_safety_prep/fn_prop_null_aware_call_suggestor_test.dart new file mode 100644 index 00000000..d7f54777 --- /dev/null +++ b/test/dart3_suggestors/null_safety_prep/fn_prop_null_aware_call_suggestor_test.dart @@ -0,0 +1,359 @@ +// 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:over_react_codemod/src/dart3_suggestors/null_safety_prep/fn_prop_null_aware_call_suggestor.dart'; +import 'package:test/test.dart'; + +import '../../resolved_file_context.dart'; +import '../../util.dart'; +import '../../util/component_usage_migrator_test.dart'; + +void main() { + final resolvedContext = SharedAnalysisContext.overReact; + + // Warm up analysis in a setUpAll so that if getting the resolved AST times out + // (which is more common for the WSD context), it fails here instead of failing the first test. + setUpAll(resolvedContext.warmUpAnalysis); + + group('FnPropNullAwareCallSuggestor', () { + late SuggestorTester testSuggestor; + + setUp(() { + testSuggestor = getSuggestorTester( + FnPropNullAwareCallSuggestor(), + resolvedContext: resolvedContext, + ); + }); + + group('handles block if conditions', () { + test('with a single condition', () async { + await testSuggestor( + expectedPatchCount: 1, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (props.onClick != null) { + props.onClick(e); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + expectedOutput: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + props.onClick?.call(e); + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + ''')); + }); + + test( + 'unless the single condition is not a null check of the function being called', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (1 > 0) { + props.onClick(e); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test('unless there is an else condition', () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final bar = useState(0); + final handleClick = useCallback((e) { + if (props.onClick != null) { + props.onClick(e); + } else { + bar.set(1); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(bar.value); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test('unless there is an else if condition', () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final bar = useState(0); + final handleClick = useCallback((e) { + if (props.onMouseEnter != null) { + bar.set(1); + } else if (props.onClick != null) { + props.onClick(e); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(bar.value); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test( + 'unless the single condition involves the function being called, but is not a null check', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (props.onClick is Function) { + props.onClick(e); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test('unless the single condition does not involve props at all', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final bar = false; + final handleClick = useCallback((e) { + if (bar) { + props.onClick(e); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test('unless there are multiple conditions', () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (props.onClick != null && props.onMouseEnter != null) { + props.onClick(e); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test('unless the relevant prop fn is returned within the then statement', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (props.onClick != null) { + return props.onClick(e); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test( + 'unless the relevant prop fn is not called within the then statement', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final bar = useState(0); + final handleClick = useCallback((e) { + if (props.onClick != null) { + bar.set(1); + } + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test('unless there are multiple statements within the then statement', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (props.onClick != null) { + props.onMouseEnter?.call(e); + props.onClick(e); + } + }, [props.onClick, props.onMouseEnter]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + }); + + group('handles inline if conditions', () { + test('with a single condition', () async { + await testSuggestor( + expectedPatchCount: 1, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (props.onClick != null) props.onClick(e); + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + expectedOutput: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + props.onClick?.call(e); + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + ''')); + }); + + test( + 'unless the single condition is not a null check of the function being called', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (1 > 0) props.onClick(e); + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test( + 'unless the single condition involves the function being called, but is not a null check', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (props.onClick is Function) props.onClick(e); + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + + test('unless there are multiple conditions', () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + final Foo = uiFunction( + (props) { + final handleClick = useCallback((e) { + if (props.onClick != null && props.onMouseEnter != null) props.onClick(e); + }, [props.onClick]); + + return (Dom.button()..onClick = handleClick)(); + }, + UiFactoryConfig(displayName: 'Foo'), + ); + '''), + ); + }); + }); + }); +} diff --git a/test/test_fixtures/over_react_project/pubspec.yaml b/test/test_fixtures/over_react_project/pubspec.yaml index f5301606..5949bbd1 100644 --- a/test/test_fixtures/over_react_project/pubspec.yaml +++ b/test/test_fixtures/over_react_project/pubspec.yaml @@ -2,4 +2,4 @@ name: over_react_project environment: sdk: '>=2.11.0 <3.0.0' dependencies: - over_react: ^4.2.0 + over_react: ^5.0.0 diff --git a/test/test_fixtures/rmui_project/pubspec.yaml b/test/test_fixtures/rmui_project/pubspec.yaml index 761c78b3..09928b7e 100644 --- a/test/test_fixtures/rmui_project/pubspec.yaml +++ b/test/test_fixtures/rmui_project/pubspec.yaml @@ -2,7 +2,7 @@ name: rmui_project environment: sdk: '>=2.11.0 <3.0.0' dependencies: - over_react: ^4.2.0 + over_react: ^5.0.0 react_material_ui: hosted: name: react_material_ui diff --git a/test/test_fixtures/wsd_project/pubspec.yaml b/test/test_fixtures/wsd_project/pubspec.yaml index bd483fad..99faa3a0 100644 --- a/test/test_fixtures/wsd_project/pubspec.yaml +++ b/test/test_fixtures/wsd_project/pubspec.yaml @@ -2,7 +2,7 @@ name: wsd_project environment: sdk: '>=2.11.0 <3.0.0' dependencies: - over_react: ^4.2.0 + over_react: ^5.0.0 web_skin_dart: hosted: name: web_skin_dart