diff --git a/bin/unify_package_rename.dart b/bin/unify_package_rename.dart new file mode 100644 index 00000000..1dad86aa --- /dev/null +++ b/bin/unify_package_rename.dart @@ -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/unify_package_rename.dart'; diff --git a/lib/src/executables/mui_migration.dart b/lib/src/executables/mui_migration.dart index 5e71c1ae..87570d1e 100644 --- a/lib/src/executables/mui_migration.dart +++ b/lib/src/executables/mui_migration.dart @@ -24,12 +24,14 @@ import 'package:logging/logging.dart'; import 'package:over_react_codemod/src/util.dart'; import 'package:over_react_codemod/src/ignoreable.dart'; import 'package:over_react_codemod/src/mui_suggestors/components.dart'; -import 'package:over_react_codemod/src/mui_suggestors/mui_importer.dart'; -import 'package:over_react_codemod/src/mui_suggestors/unused_wsd_import_remover.dart'; import 'package:over_react_codemod/src/util/package_util.dart'; import 'package:over_react_codemod/src/util/pubspec_upgrader.dart'; import 'package:over_react_codemod/src/util/logging.dart'; +import '../mui_suggestors/constants.dart'; +import '../util/importer.dart'; +import '../util/unused_import_remover.dart'; + final _log = Logger('orcm.mui_migration'); const _componentOption = 'component'; @@ -188,8 +190,10 @@ void main(List args) async { // should only be handled by a single migrator, and shouldn't depend on the // output of previous migrators. [aggregate(migratorsToRun)], - [muiImporter], - [unusedWsdImportRemover], + [ + importerSuggestorBuilder(importUri: rmuiImportUri, importNamespace: muiNs) + ], + [unusedImportRemoverSuggestorBuilder('web_skin_dart')], ]); if (exitCode != 0) return; diff --git a/lib/src/executables/unify_package_rename.dart b/lib/src/executables/unify_package_rename.dart new file mode 100644 index 00000000..d133e884 --- /dev/null +++ b/lib/src/executables/unify_package_rename.dart @@ -0,0 +1,114 @@ +// 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:over_react_codemod/src/executables/mui_migration.dart'; +import 'package:over_react_codemod/src/rmui_bundle_update_suggestors/constants.dart'; +import 'package:over_react_codemod/src/rmui_bundle_update_suggestors/dart_script_updater.dart'; +import 'package:over_react_codemod/src/rmui_bundle_update_suggestors/html_script_updater.dart'; +import 'package:over_react_codemod/src/unify_package_rename_suggestors/constants.dart'; +import 'package:over_react_codemod/src/unify_package_rename_suggestors/import_renamer.dart'; +import 'package:over_react_codemod/src/unify_package_rename_suggestors/unify_rename_suggestor.dart'; +import 'package:over_react_codemod/src/util.dart'; + +import '../util/importer.dart'; +import '../util/unused_import_remover.dart'; + +const _changesRequiredOutput = """ + To update your code, run the following commands in your repository: + dart pub global activate over_react_codemod + dart pub global run over_react_codemod:unify_package_rename +"""; + +class CodemodInfo { + CodemodInfo({required this.paths, required this.sequence}); + Iterable paths; + Iterable sequence; +} + +void main(List args) async { + final parser = ArgParser.allowAnything(); + + final parsedArgs = parser.parse(args); + + /// Runs a list of codemods one after the other and returns exit code 0 if any fail. + Future runCodemods( + Iterable codemods, + ) async { + for (final sequence in codemods) { + final exitCode = await runInteractiveCodemodSequence( + sequence.paths, + sequence.sequence, + defaultYes: true, + args: parsedArgs.rest, + additionalHelpOutput: parser.usage, + changesRequiredOutput: _changesRequiredOutput, + ); + if (exitCode != 0) return exitCode; + } + + return 0; + } + + exitCode = await runCodemods([ + // Update RMUI bundle script in all HTML files (and templates) to Unify bundle. + CodemodInfo(paths: allHtmlPathsIncludingTemplates(), sequence: [ + HtmlScriptUpdater(rmuiBundleDevUpdated, unifyBundleDev), + HtmlScriptUpdater(rmuiBundleProdUpdated, unifyBundleProd), + ]), + // Update RMUI bundle script in all Dart files to Unify bundle. + CodemodInfo(paths: allDartPathsExceptHidden(), sequence: [ + DartScriptUpdater(rmuiBundleDevUpdated, unifyBundleDev), + DartScriptUpdater(rmuiBundleProdUpdated, unifyBundleProd), + ]), + ]); + + if (exitCode != 0) return; + + final dartPaths = dartFilesToMigrate().toList(); + // Work around parts being unresolved if you resolve them before their libraries. + // TODO - reference analyzer issue for this once it's created + sortPartsLast(dartPaths); + + await pubGetForAllPackageRoots(dartPaths); + exitCode = await runCodemods([ + // Make main rename updates. + CodemodInfo(paths: dartPaths, sequence: [UnifyRenameSuggestor()]), + // Add WSD entrypoint imports as needed. + CodemodInfo(paths: dartPaths, sequence: [ + importerSuggestorBuilder( + importUri: unifyWsdUri, + importNamespace: unifyWsdNamespace, + ) + ]), + // Update rmui imports to unify. + CodemodInfo(paths: dartPaths, sequence: [ + importRenamerSuggestorBuilder( + oldPackageName: 'react_material_ui', + newPackageName: 'unify_ui', + oldPackageNamespace: 'mui', + newPackageNamespace: 'unify', + ) + ]), + // Remove any left over unused imports. + CodemodInfo(paths: dartPaths, sequence: [ + unusedImportRemoverSuggestorBuilder('react_material_ui'), + unusedImportRemoverSuggestorBuilder('unify_ui'), + ]), + ]); + if (exitCode != 0) return; +} diff --git a/lib/src/mui_suggestors/constants.dart b/lib/src/mui_suggestors/constants.dart index 19cfd26d..99790475 100644 --- a/lib/src/mui_suggestors/constants.dart +++ b/lib/src/mui_suggestors/constants.dart @@ -18,3 +18,6 @@ /// is what `muiImporter` picks up on to add the react_material_ui import to /// libraries only when it's needed. const muiNs = 'mui'; + +/// The import uri for the main react_material_ui entrypoint. +const rmuiImportUri = 'package:react_material_ui/react_material_ui.dart'; diff --git a/lib/src/mui_suggestors/unused_wsd_import_remover.dart b/lib/src/mui_suggestors/unused_wsd_import_remover.dart deleted file mode 100644 index 3392b573..00000000 --- a/lib/src/mui_suggestors/unused_wsd_import_remover.dart +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2021 Workiva Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/syntactic_entity.dart'; -import 'package:codemod/codemod.dart'; -import 'package:logging/logging.dart'; - -final _log = Logger('unusedWsdImportRemover'); - -/// A suggestor that removes unused imports for WSD. -Stream unusedWsdImportRemover(FileContext context) async* { - final unitResult = await context.getResolvedUnit(); - if (unitResult == null) { - // Most likely a part and not a library. - return; - } - final unusedImportErrors = unitResult.errors - .where((error) => error.errorCode.name.toLowerCase() == 'unused_import') - .toList(); - - final allImports = - unitResult.unit.directives.whereType().toList(); - - for (final error in unusedImportErrors) { - final matchingImport = - allImports.singleWhere((import) => import.containsOffset(error.offset)); - final importUri = matchingImport.uri.stringValue; - if (importUri != null && importUri.startsWith('package:web_skin_dart/')) { - final prevTokenEnd = matchingImport.beginToken.previous?.end; - // Try to take the newline before the import, but watch out - // for prevToken's offset/end being -1 if it's this import has the - // first token in the file. - final startOffset = prevTokenEnd != null && prevTokenEnd != -1 - ? prevTokenEnd - : matchingImport.offset; - yield Patch('', startOffset, matchingImport.end); - } - } -} - -extension on SyntacticEntity { - bool containsOffset(int offset) => offset >= this.offset && offset < end; -} diff --git a/lib/src/rmui_bundle_update_suggestors/constants.dart b/lib/src/rmui_bundle_update_suggestors/constants.dart index e3b81060..b8fe71e0 100644 --- a/lib/src/rmui_bundle_update_suggestors/constants.dart +++ b/lib/src/rmui_bundle_update_suggestors/constants.dart @@ -27,6 +27,12 @@ const rmuiBundleDevUpdated = const rmuiBundleProdUpdated = 'packages/react_material_ui/js/react-material-ui.browser.min.esm.js'; +/// The script for the dev Unify bundle. +const unifyBundleDev = 'packages/unify_ui/js/unify-ui.browser.dev.esm.js'; + +/// The script for the prod Unify bundle. +const unifyBundleProd = 'packages/unify_ui/js/unify-ui.browser.min.esm.js'; + /// The type attribute that needs to be added to script tags for the new RMUI bundles. final typeModuleAttribute = 'type="module"'; diff --git a/lib/src/unify_package_rename_suggestors/constants.dart b/lib/src/unify_package_rename_suggestors/constants.dart new file mode 100644 index 00000000..e4d92fc6 --- /dev/null +++ b/lib/src/unify_package_rename_suggestors/constants.dart @@ -0,0 +1,141 @@ +// 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. + +/// Info on a unify_ui import. +class UnifyImportInfo { + UnifyImportInfo(this.uri, + {this.rmuiUri, + this.namespace, + this.possibleMuiNamespaces, + this.showHideInfo}); + + /// Unify import URI. + String uri; + + /// Recommended Unify version of the import namespace, if applicable. + String? namespace; + + /// List of common RMUI versions of the namespace for the import, if applicable. + List? possibleMuiNamespaces; + + /// Previous RMUI import URI (if it's different from the unify_ui path). + String? rmuiUri; + + /// Additional show / hide information used in [importRenamerSuggestorBuilder] to add to updated imports. + String? showHideInfo; +} + +/// A list of the standard imports for unify_ui that should be updated. +/// +/// Only adds namespace information if the import is commonly used with a namespace. +/// Only adds RMUI uri information if it is different from a simple package name swap. +final rmuiImportsToUpdate = [ + UnifyImportInfo( + 'package:unify_ui/unify_ui.dart', + rmuiUri: 'package:react_material_ui/react_material_ui.dart', + namespace: 'unify', + possibleMuiNamespaces: ['mui', 'rmui'], + ), + UnifyImportInfo( + 'package:unify_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart', + rmuiUri: + 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart', + namespace: 'alpha_unify', + possibleMuiNamespaces: ['alpha_mui', 'mui_alpha'], + ), + UnifyImportInfo( + 'package:unify_ui/components/list.dart', + rmuiUri: 'package:react_material_ui/components/mui_list.dart', + ), + UnifyImportInfo( + 'package:unify_ui/styles/styled.dart', + rmuiUri: 'package:react_material_ui/for_cp_use_only/styled.dart', + ), + UnifyImportInfo('package:unify_ui/styles/theme_provider.dart', + rmuiUri: 'package:react_material_ui/styles/theme_provider.dart', + namespace: 'unify_theme', + possibleMuiNamespaces: ['mui_theme']) +]; + +/// A map of RMUI component names to their new names in unify_ui. +/// +/// This is based on the list of changes in the migration guide: https://github.com/Workiva/react_material_ui/tree/master/react_material_ui#how-to-migrate-from-reactmaterialui-to-unifyui +const rmuiToUnifyIdentifierRenames = { + // Components + 'Alert': 'WsdAlert', + 'AlertPropsMixin': 'WsdAlertPropsMixin', + 'LinkButton': 'WsdLinkButton', + 'LinkButtonPropsMixin': 'WsdLinkButtonPropsMixin', + 'MuiList': 'UnifyList', + 'MuiListPropsMixin': 'UnifyListPropsMixin', + 'WorkivaMuiThemeProvider': 'UnifyThemeProvider', + 'WorkivaMuiThemeProviderPropsMixin': 'UnifyThemeProviderPropsMixin', + // Alert objects + 'AlertIconMappingObject': 'WsdAlertIconMappingObject', + // Autocomplete objects + 'AutocompleteFilterOptionsObject': 'AutocompleteFilterOptionsState', + 'AutocompleteOnChangeObject': 'AutocompleteChangeDetails', + 'AutocompleteRenderOptionObject': 'AutocompleteRenderOptionState', + // Backdrop objects + 'BackdropTimeoutObject': 'BackdropObject', + 'BackdropTransitionDurationObject': 'BackdropObject', + // Badge objects + 'BadgeAnchorOriginObject': 'BadgeOrigin', + 'BadgeAnchorOriginObjectVertical': 'BadgeOriginVertical', + 'BadgeAnchorOriginObjectHorizontal': 'BadgeOriginHorizontal', + // Breadcrumb objects + 'BreadcrumbNavCrumbsObject': 'BreadcrumbNavBreadcrumbModel', + // CSSTransition objects + 'CSSTransitionClassNamesObject': 'CSSTransitionClassNames', + // DropdownButton objects + 'DropdownButtonOnPlacementUpdate': 'DropdownButtonPlacement', + // Menu objects + 'MenuAnchorOriginObject': 'MenuPopoverOrigin', + 'MenuTransformOriginObject': 'MenuPopoverOrigin', + 'MenuAnchorOriginObjectVertical': 'MenuPopoverOriginVertical', + 'MenuTransformOriginObjectVertical': 'MenuPopoverOriginVertical', + 'MenuAnchorOriginObjectHorizontal': 'MenuPopoverOriginHorizontal', + 'MenuTransformOriginObjectHorizontal': 'MenuPopoverOriginHorizontal', + 'MenuAnchorPositionObject': 'MenuPopoverPosition', + // Popover objects + 'PopoverAnchorOriginObject': 'PopoverOrigin', + 'PopoverTransformOriginObject': 'PopoverOrigin', + 'PopoverAnchorOriginObjectVertical': 'PopoverOriginVertical', + 'PopoverTransformOriginObjectVertical': 'PopoverOriginVertical', + 'PopoverAnchorOriginObjectHorizontal': 'PopoverOriginHorizontal', + 'PopoverTransformOriginObjectHorizontal': 'PopoverOriginHorizontal', + 'PopoverAnchorPositionObject': 'PopoverPosition', + // Popper objects + 'PopperAnchorElObject': 'PopperVirtualElement', + 'PopperModifiersObject': 'PopperModifier', + 'PopperModifiersObjectPhase': 'PopperModifierPhases', + 'PopperPopperOptionsObject': 'PopperOptionsGeneric', + 'PopperPopperOptionsObjectPlacement': 'PopperPlacement', + 'PopperPopperOptionsObjectStrategy': 'PopperPositioningStrategy', + // Slider objects + 'SliderMarksObject': 'SliderMark', + // Snackbar objects + 'SnackbarAnchorOriginObject': 'SnackbarOrigin', + 'SnackbarAnchorOriginObjectVertical': 'SnackbarOriginVertical', + 'SnackbarAnchorOriginObjectHorizontal': 'SnackbarOriginHorizontal', + // TablePagination objects + 'TablePaginationLabelDisplayedRowsObject': + 'TablePaginationLabelDisplayedRowsArgs', +}; + +/// The namespace that will be used for the `unify_ui/components/wsd.dart` import that is added. +const unifyWsdNamespace = 'unify_wsd'; + +/// The uri for the `unify_ui/components/wsd.dart` import that is added. +const unifyWsdUri = 'package:unify_ui/components/wsd.dart'; diff --git a/lib/src/unify_package_rename_suggestors/import_renamer.dart b/lib/src/unify_package_rename_suggestors/import_renamer.dart new file mode 100644 index 00000000..e9b2948e --- /dev/null +++ b/lib/src/unify_package_rename_suggestors/import_renamer.dart @@ -0,0 +1,109 @@ +// 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/ast/ast.dart'; +import 'package:codemod/codemod.dart'; + +import '../util/importer.dart'; +import 'constants.dart'; + +/// Suggestor that updates imports from [oldPackageName] to [newPackageName]. +Suggestor importRenamerSuggestorBuilder({ + required String oldPackageName, + required String newPackageName, + required String oldPackageNamespace, + required String newPackageNamespace, +}) { + return (context) async* { + final libraryResult = await context.getResolvedLibrary(); + if (libraryResult == null) { + // Most likely a part and not a library. + return; + } + + // Parts that have not been generated can show up as `exists = false` but also `isPart = false`, + // so using the unitResults is a little trickier than using the libraryElement to get it. + final mainLibraryUnitResult = libraryResult.units.singleWhere( + (unitResult) => + unitResult.unit.declaredElement == + libraryResult.element.definingCompilationUnit); + + // Look for imports with old package name. + final importsToUpdate = mainLibraryUnitResult.unit.directives + .whereType() + .where((import) => + import.uri.stringValue?.startsWith('package:$oldPackageName/') ?? + false); + + final newImportsInfo = []; + for (final import in importsToUpdate) { + final importUri = import.uri.stringValue; + final namespace = import.prefix?.name; + var newImportUri = importUri?.replaceFirst( + 'package:$oldPackageName/', 'package:$newPackageName/'); + var newNamespace = + namespace == oldPackageNamespace ? newPackageNamespace : namespace; + + // Check for special cases where the unify_ui import path does not match the previous RMUI path. + final specialCaseRmuiImport = + rmuiImportsToUpdate.where((i) => importUri == i.rmuiUri); + if (specialCaseRmuiImport.isNotEmpty) { + newImportUri = specialCaseRmuiImport.single.uri; + + final specialCaseNamespace = specialCaseRmuiImport.single.namespace; + if (namespace != null && + specialCaseNamespace != null && + (specialCaseRmuiImport.single.possibleMuiNamespaces + ?.contains(namespace) ?? + false)) { + newNamespace = specialCaseNamespace; + } + } + + if (newImportUri != null) { + // Collect info on new imports to add. + newImportsInfo.add(UnifyImportInfo(newImportUri, + namespace: newNamespace, + showHideInfo: import.combinators + .map((c) => c.toSource()) + .toList() + .join(' '))); + } + + final prevTokenEnd = import.beginToken.previous?.end; + // Try to take the newline before the import, but watch out + // for prevToken's offset/end being -1 if it's this import has the + // first token in the file. + final startOffset = prevTokenEnd != null && prevTokenEnd != -1 + ? prevTokenEnd + : import.offset; + yield Patch('', startOffset, import.end); + } + + // Sort imports before adding them. + newImportsInfo.sort((a, b) => a.uri.compareTo(b.uri)); + + // Add imports in their alphabetical positions. + for (final importInfo in newImportsInfo) { + final insertInfo = insertionLocationForPackageImport(importInfo.uri, + mainLibraryUnitResult.unit, mainLibraryUnitResult.lineInfo); + yield Patch( + insertInfo.leadingNewlines + + "import '${importInfo.uri}'${importInfo.namespace != null ? ' as ${importInfo.namespace}' : ''}${importInfo.showHideInfo != null ? ' ${importInfo.showHideInfo}' : ''};" + + insertInfo.trailingNewlines, + insertInfo.offset, + insertInfo.offset); + } + }; +} diff --git a/lib/src/unify_package_rename_suggestors/unify_rename_suggestor.dart b/lib/src/unify_package_rename_suggestors/unify_rename_suggestor.dart new file mode 100644 index 00000000..7480e6c9 --- /dev/null +++ b/lib/src/unify_package_rename_suggestors/unify_rename_suggestor.dart @@ -0,0 +1,164 @@ +// 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/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:codemod/codemod.dart'; +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; + +import '../util.dart'; +import '../util/class_suggestor.dart'; +import '../util/element_type_helpers.dart'; +import 'constants.dart'; + +final _log = Logger('UnifyRenameSuggestor'); + +/// Suggestor that performs all the updates needed to migrate from the react_material_ui package +/// to the unify_ui package: +/// +/// - Rename specific components and objects +/// - Update WSD ButtonColor usages +/// - Rename import namespaces 'mui' => 'unify' +/// - Add fix me comments for manual checks +/// +/// Also see migration guide: https://github.com/Workiva/react_material_ui/tree/master/react_material_ui#how-to-migrate-from-reactmaterialui-to-unifyui +class UnifyRenameSuggestor extends GeneralizingAstVisitor with ClassSuggestor { + UnifyRenameSuggestor(); + + @override + visitMethodInvocation(MethodInvocation node) { + super.visitMethodInvocation(node); + + // Replace 'mui' namespaces usage with 'unify' for method invocations. + final uri = node.methodName.staticElement?.source?.uri; + if (uri != null && + (isUriWithinPackage(uri, 'react_material_ui') || + isUriWithinPackage(uri, 'unify_ui'))) { + final importNamespace = node.target; + if (importNamespace != null) { + final newImportNamespace = rmuiImportsToUpdate + .where((import) => + import.possibleMuiNamespaces + ?.contains(importNamespace.toSource()) ?? + false) + .singleOrNull + ?.namespace; + if (newImportNamespace != null) { + yieldPatch( + newImportNamespace, importNamespace.offset, importNamespace.end); + } + } + } + } + + @override + visitIdentifier(Identifier node) { + super.visitIdentifier(node); + + // Check that the parent isn't a prefixed identifier to avoid conflicts if the parent was already updated. + if (node.parent is PrefixedIdentifier) { + return; + } + + final identifier = node.tryCast() ?? + node.tryCast()?.identifier; + final uri = identifier?.staticElement?.source?.uri; + final prefix = node.tryCast()?.prefix; + + if (uri != null && + (isUriWithinPackage(uri, 'react_material_ui') || + isUriWithinPackage(uri, 'unify_ui'))) { + // Update components and objects that were renamed in unify_ui. + final newName = rmuiToUnifyIdentifierRenames[identifier?.name]; + var isFromWsdEntrypoint = newName?.startsWith('Wsd') ?? false; + if (identifier != null && newName != null) { + if (isFromWsdEntrypoint) { + // Overwrite or add import namespace for components that will be imported from the separate + // unify_ui/components/wsd.dart entrypoint so we can keep the namespace of the import + // we add consistent with the components that use it. + yieldPatch('$unifyWsdNamespace.$newName', node.offset, node.end); + } else { + yieldPatch(newName, identifier.offset, identifier.end); + } + } + + // Update WSD ButtonColor and AlertSize usages. + { + // Update WSD constant properties objects to use the WSD versions if applicable. + yieldWsdRenamePatchIfApplicable( + Expression node, String? objectName, String? propertyName) { + const alertConstantNames = [ + 'AlertSize', + 'AlertColor', + 'AlertVariant', + 'AlertSeverity' + ]; + if (objectName == 'ButtonColor' && + (propertyName?.startsWith('wsd') ?? false)) { + isFromWsdEntrypoint = true; + yieldPatch('$unifyWsdNamespace.WsdButtonColor.$propertyName', + node.offset, node.end); + } else if (alertConstantNames.contains(objectName)) { + isFromWsdEntrypoint = true; + yieldPatch('$unifyWsdNamespace.Wsd$objectName.$propertyName', + node.offset, node.end); + } + } + + final parent = node.parent; + // Check for non-namespaced `ButtonColor.wsd...` usage. + yieldWsdRenamePatchIfApplicable(node, prefix?.name, identifier?.name); + // Check for namespaced `mui.ButtonColor.wsd...` usage. + if (node is PrefixedIdentifier && parent is PropertyAccess) { + yieldWsdRenamePatchIfApplicable( + parent, identifier?.name, parent.propertyName.name); + } + } + + // Replace 'mui' namespaces usage with 'unify'. + final newNamespace = rmuiImportsToUpdate + .where((import) => + import.possibleMuiNamespaces?.contains(prefix?.name) ?? false) + .singleOrNull + ?.namespace; + if (prefix != null && newNamespace != null && !isFromWsdEntrypoint) { + yieldPatch(newNamespace, prefix.offset, prefix.end); + } + + // Add comments for components that need manual verification. + if (identifier?.name == 'Badge' || identifier?.name == 'LinearProgress') { + yieldInsertionPatch( + lineComment( + 'FIXME(unify_package_rename) Check what theme provider is wrapping this component: if it is a UnifyThemeProvider, remove this FIXME - no action is required; otherwise, migrate this component back to Web Skin Dart.'), + node.offset); + } + } + } + + @override + Future generatePatches() async { + _log.info('Resolving ${context.relativePath}...'); + + final result = await context.getResolvedUnit(); + if (result == null) { + throw Exception( + 'Could not get resolved result for "${context.relativePath}"'); + } + result.unit.visitChildren(this); + } + + @override + bool shouldSkip(FileContext context) => hasParseErrors(context.sourceText); +} diff --git a/lib/src/mui_suggestors/mui_importer.dart b/lib/src/util/importer.dart similarity index 68% rename from lib/src/mui_suggestors/mui_importer.dart rename to lib/src/util/importer.dart index 98e20656..954754b6 100644 --- a/lib/src/mui_suggestors/mui_importer.dart +++ b/lib/src/util/importer.dart @@ -18,45 +18,48 @@ import 'package:codemod/codemod.dart'; import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; -import 'constants.dart'; - final _log = Logger('muiImporter'); -/// A suggestor that adds imports in libraries that reference -/// the [muiNs] import namespace (including in parts) but don't yet import it. -Stream muiImporter(FileContext context) async* { - final libraryResult = await context.getResolvedLibrary(); - if (libraryResult == null) { - // Most likely a part and not a library. - return; - } - - // Parts that have not been generated can show up as `exists = false` but also `isPart = false`, - // so using the unitResults is a little trickier than using the libraryElement to get it. - final mainLibraryUnitResult = libraryResult.units.singleWhere((unitResult) => - unitResult.unit.declaredElement == - libraryResult.element.definingCompilationUnit); - - // Look for errors in the main compilation unit and its part files. - // Ignore null partContexts and partContexts elements caused by - // resolution issues and parts being excluded in the codemod file list. - final needsMuiImport = libraryResult.units - .expand((unitResult) => unitResult.errors) - .where((error) => error.errorCode.name == 'UNDEFINED_IDENTIFIER') - .any((error) => error.message.contains("Undefined name '$muiNs'")); - - if (!needsMuiImport) return; - - const rmuiImportUri = 'package:react_material_ui/react_material_ui.dart'; - - final insertInfo = _insertionLocationForPackageImport(rmuiImportUri, - mainLibraryUnitResult.unit, mainLibraryUnitResult.lineInfo); - yield Patch( - insertInfo.leadingNewlines + - "import '$rmuiImportUri' as $muiNs;" + - insertInfo.trailingNewlines, - insertInfo.offset, - insertInfo.offset); +/// Creates a suggestor that adds [importUri] imports in libraries that reference +/// the [importNamespace] (including in parts) but don't yet import it. +Suggestor importerSuggestorBuilder({ + required String importUri, + required String importNamespace, +}) { + return (context) async* { + final libraryResult = await context.getResolvedLibrary(); + if (libraryResult == null) { + // Most likely a part and not a library. + return; + } + + // Parts that have not been generated can show up as `exists = false` but also `isPart = false`, + // so using the unitResults is a little trickier than using the libraryElement to get it. + final mainLibraryUnitResult = libraryResult.units.singleWhere( + (unitResult) => + unitResult.unit.declaredElement == + libraryResult.element.definingCompilationUnit); + + // Look for errors in the main compilation unit and its part files. + // Ignore null partContexts and partContexts elements caused by + // resolution issues and parts being excluded in the codemod file list. + final needsImport = libraryResult.units + .expand((unitResult) => unitResult.errors) + .where((error) => error.errorCode.name == 'UNDEFINED_IDENTIFIER') + .any((error) => + error.message.contains("Undefined name '$importNamespace'")); + + if (!needsImport) return; + + final insertInfo = insertionLocationForPackageImport( + importUri, mainLibraryUnitResult.unit, mainLibraryUnitResult.lineInfo); + yield Patch( + insertInfo.leadingNewlines + + "import '$importUri' as $importNamespace;" + + insertInfo.trailingNewlines, + insertInfo.offset, + insertInfo.offset); + }; } class _InsertionLocation { @@ -79,7 +82,7 @@ class _InsertionLocation { /// insert it alongside other `package:` imports in alphabetical order, /// otherwise inserting it in a new section relative to other imports /// or other directives. -_InsertionLocation _insertionLocationForPackageImport( +_InsertionLocation insertionLocationForPackageImport( String importUri, CompilationUnit unit, LineInfo lineInfo) { final imports = unit.directives.whereType(); final firstImport = imports.firstOrNull; diff --git a/lib/src/util/unused_import_remover.dart b/lib/src/util/unused_import_remover.dart new file mode 100644 index 00000000..07aeb559 --- /dev/null +++ b/lib/src/util/unused_import_remover.dart @@ -0,0 +1,57 @@ +// Copyright 2021 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/syntactic_entity.dart'; +import 'package:codemod/codemod.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('unusedWsdImportRemover'); + +/// Creates a suggestor that removes unused [package] imports. +Suggestor unusedImportRemoverSuggestorBuilder(String package) { + return (context) async* { + final unitResult = await context.getResolvedUnit(); + if (unitResult == null) { + // Most likely a part and not a library. + return; + } + final unusedImportErrors = unitResult.errors + .where((error) => error.errorCode.name.toLowerCase() == 'unused_import') + .toList(); + + final allImports = + unitResult.unit.directives.whereType().toList(); + + for (final error in unusedImportErrors) { + final matchingImport = allImports + .singleWhere((import) => import.containsOffset(error.offset)); + final importUri = matchingImport.uri.stringValue; + if (importUri != null && importUri.startsWith('package:$package/')) { + final prevTokenEnd = matchingImport.beginToken.previous?.end; + // Try to take the newline before the import, but watch out + // for prevToken's offset/end being -1 if it's this import has the + // first token in the file. + final startOffset = prevTokenEnd != null && prevTokenEnd != -1 + ? prevTokenEnd + : matchingImport.offset; + yield Patch('', startOffset, matchingImport.end); + } + } + }; +} + +extension on SyntacticEntity { + bool containsOffset(int offset) => offset >= this.offset && offset < end; +} diff --git a/pubspec.yaml b/pubspec.yaml index 65297da1..f2d6d92f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ executables: rmui_preparation: rmui_bundle_update: intl_message_migration: + unify_package_rename: dependency_validator: ignore: - meta diff --git a/skynet.yaml b/skynet.yaml index 2fc31ad4..479ba68c 100644 --- a/skynet.yaml +++ b/skynet.yaml @@ -14,6 +14,7 @@ scripts: - 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 diff --git a/test/resolved_file_context.dart b/test/resolved_file_context.dart index 9fd14d3b..e8ae738c 100644 --- a/test/resolved_file_context.dart +++ b/test/resolved_file_context.dart @@ -59,6 +59,11 @@ class SharedAnalysisContext { 'If this fails to resolve in GitHub Actions, make sure your test or' ' test group is tagged with "wsd" so that it\'s only run in Skynet.'); + /// A context root located at `test/test_fixtures/rmui_project` + /// that depends on the `react_material_ui` package (as well as `over_react`). + static final rmui = SharedAnalysisContext( + p.join(findPackageRootFor(p.current), 'test/test_fixtures/rmui_project')); + /// The path to the package root in which test files will be created /// and resolved. final String _path; diff --git a/test/test_fixtures/rmui_project/README.md b/test/test_fixtures/rmui_project/README.md new file mode 100644 index 00000000..0f35e921 --- /dev/null +++ b/test/test_fixtures/rmui_project/README.md @@ -0,0 +1,5 @@ +A package that depends on react_material_ui, and can be used as a context root for tests that require a resolved analysis context with access to react_material_ui APIs. + +This is separate from over_react_project, since react_material_ui is only required for some tests, and fetching it as a dependency requires access to a private Pub server, meaning it can't be run in GitHub Actions. + +To use, see `SharedAnalysisContext.rmui`. diff --git a/test/test_fixtures/rmui_project/lib/analysis_warmup.dart b/test/test_fixtures/rmui_project/lib/analysis_warmup.dart new file mode 100644 index 00000000..6ff6f99c --- /dev/null +++ b/test/test_fixtures/rmui_project/lib/analysis_warmup.dart @@ -0,0 +1,10 @@ +// This file imports RMUI and over_react and can be analyzed to warm up an +// analysis context during testing. + +import 'package:react_material_ui/react_material_ui.dart'; +import 'package:over_react/over_react.dart'; + +main() { + Button()(); + Dom.div()(); +} diff --git a/test/test_fixtures/rmui_project/pubspec.yaml b/test/test_fixtures/rmui_project/pubspec.yaml new file mode 100644 index 00000000..761c78b3 --- /dev/null +++ b/test/test_fixtures/rmui_project/pubspec.yaml @@ -0,0 +1,10 @@ +name: rmui_project +environment: + sdk: '>=2.11.0 <3.0.0' +dependencies: + over_react: ^4.2.0 + react_material_ui: + hosted: + name: react_material_ui + url: https://pub.workiva.org + version: ^1.121.0 diff --git a/test/unify_package_rename_suggestors/import_renamer_test.dart b/test/unify_package_rename_suggestors/import_renamer_test.dart new file mode 100644 index 00000000..7d9c6717 --- /dev/null +++ b/test/unify_package_rename_suggestors/import_renamer_test.dart @@ -0,0 +1,240 @@ +// 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:over_react_codemod/src/unify_package_rename_suggestors/import_renamer.dart'; +import 'package:test/test.dart'; + +import '../util.dart'; + +void main() { + group('importRenamer', () { + final testSuggestor = getSuggestorTester( + importRenamerSuggestorBuilder( + oldPackageName: 'react_material_ui', + newPackageName: 'unify_ui', + oldPackageNamespace: 'mui', + newPackageNamespace: 'unify', + ), + ); + + test('does nothing when there are no imports', () async { + await testSuggestor(input: ''); + }); + + test('does nothing for non-react_material_ui imports', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:over_react/over_react.dart'; + ''', + ); + }); + + // All tests strings are split by package name to work around issues with dependency_validator. + group('updates react_material_ui imports in a file', () { + test('', () async { + await testSuggestor( + input: ''' + import 'package:over_react/over_react.dart'; + import 'package:react_material_ui/react_material_ui.dart'; + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart'; + import 'package:react_material_ui/components/badge.dart'; + import 'package:react_material_ui/abc.dart'; + + content() => Dom.div()(); + ''', + expectedOutput: ''' + import 'package:over_react/over_react.dart'; + import 'package:''' + '''unify_ui/abc.dart'; + import 'package:''' + '''unify_ui/components/badge.dart'; + import 'package:''' + '''unify_ui/unify_ui.dart'; + import 'package:''' + '''unify_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart'; + + content() => Dom.div()(); + ''', + ); + }); + + test('in alphabetical order', () async { + await testSuggestor( + input: ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/styles/styled.dart' as mui; + + content() => Dom.div()(); + ''', + expectedOutput: ''' + import 'package:''' + '''unify_ui/styles/styled.dart' as unify; + import 'package:''' + '''unify_ui/unify_ui.dart' as unify; + + content() => Dom.div()(); + ''', + ); + }); + + test( + 'for special cases when the new file path is different from the old one', + () async { + await testSuggestor( + input: ''' + import 'package:react_material_ui/react_material_ui.dart'; + import 'package:over_react/over_react.dart'; + import 'package:web_skin_dart/ui_components.dart'; + import 'package:react_material_ui/for_cp_use_only/styled.dart'; + import 'package:react_material_ui/components/mui_list.dart'; + import 'package:react_material_ui/styles/styled.dart'; + + content() => Dom.div()(); + ''', + expectedOutput: ''' + import 'package:over_react/over_react.dart'; + import 'package:''' + '''unify_ui/components/list.dart'; + import 'package:''' + '''unify_ui/styles/styled.dart'; + import 'package:''' + '''unify_ui/styles/styled.dart'; + import 'package:''' + '''unify_ui/unify_ui.dart'; + import 'package:web_skin_dart/ui_components.dart'; + + content() => Dom.div()(); + ''', + ); + }); + + test('with namespaces', () async { + await testSuggestor( + input: ''' + library lib; + + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as alpha_mui; + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as mui_alpha; + import 'package:over_react/over_react.dart' as mui; + import 'package:react_material_ui/components/badge.dart' as mui; + import 'package:react_material_ui/components/alert.dart' as something_else; + import 'package:react_material_ui/styles/theme_provider.dart' as mui_theme show UnifyThemeProvider; + + content() => Dom.div()(); + ''', + expectedOutput: ''' + library lib; + + import 'package:over_react/over_react.dart' as mui; + import 'package:''' + '''unify_ui/components/alert.dart' as something_else; + import 'package:''' + '''unify_ui/components/badge.dart' as unify; + import 'package:''' + '''unify_ui/styles/theme_provider.dart' as unify_theme show UnifyThemeProvider; + import 'package:''' + '''unify_ui/unify_ui.dart' as unify; + import 'package:''' + '''unify_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as alpha_unify; + import 'package:''' + '''unify_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as alpha_unify; + + content() => Dom.div()(); + ''', + ); + }); + + test('with show / hide', () async { + await testSuggestor( + input: ''' + import 'package:react_material_ui/react_material_ui.dart' hide Alert; + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as mui_alpha hide Alert show LinearProgress; + import 'package:react_material_ui/components/badge.dart' show Badge hide BadgeColor; + import 'package:react_material_ui/components/alert.dart' as something_else show Alert; + import 'package:react_material_ui/styles/theme_provider.dart' as mui_theme show UnifyThemeProvider; + + content() => Dom.div()(); + ''', + expectedOutput: ''' + import 'package:''' + '''unify_ui/components/alert.dart' as something_else show Alert; + import 'package:''' + '''unify_ui/components/badge.dart' show Badge hide BadgeColor; + import 'package:''' + '''unify_ui/styles/theme_provider.dart' as unify_theme show UnifyThemeProvider; + import 'package:''' + '''unify_ui/unify_ui.dart' hide Alert; + import 'package:''' + '''unify_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as alpha_unify hide Alert show LinearProgress; + + content() => Dom.div()(); + ''', + ); + }); + + test('unless the imports are already updated to the new name', () async { + await testSuggestor( + input: ''' + library lib; + + import 'package:''' + '''unify_ui/unify_ui.dart' as mui; + import 'package:''' + '''unify_ui/unify_ui.dart'; + import 'package:''' + '''unify_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as mui_alpha; + import 'package:''' + '''unify_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart'; + import 'package:''' + '''unify_ui/abc.dart'; + + content() => Dom.div()(); + ''', + ); + }); + + test('also works for other package name inputs', () async { + final testSuggestor = getSuggestorTester( + importRenamerSuggestorBuilder( + oldPackageName: 'old', + newPackageName: 'new', + oldPackageNamespace: 'o', + newPackageNamespace: 'n', + ), + ); + await testSuggestor( + input: ''' + import 'package:over_react/over_react.dart'; + import 'package:''' + '''old/old.dart' as o; + import 'package:''' + '''old/components/badge.dart'; + + content() => Dom.div()(); + ''', + expectedOutput: ''' + import 'package:''' + '''new/components/badge.dart'; + import 'package:''' + '''new/old.dart' as n; + import 'package:over_react/over_react.dart'; + + content() => Dom.div()(); + ''', + ); + }); + }); + }); +} diff --git a/test/unify_package_rename_suggestors/unify_rename_suggestor_test.dart b/test/unify_package_rename_suggestors/unify_rename_suggestor_test.dart new file mode 100644 index 00000000..139cad2d --- /dev/null +++ b/test/unify_package_rename_suggestors/unify_rename_suggestor_test.dart @@ -0,0 +1,433 @@ +// 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:over_react_codemod/src/unify_package_rename_suggestors/unify_rename_suggestor.dart'; +import 'package:test/test.dart'; + +import '../resolved_file_context.dart'; +import '../util.dart'; + +void main() { + final resolvedContext = SharedAnalysisContext.rmui; + + // 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('UnifyRenameSuggestor', () { + final testSuggestor = getSuggestorTester( + UnifyRenameSuggestor(), + resolvedContext: resolvedContext, + ); + + group('namespace on component usage', () { + test('mui namespace from react_material_ui is migrated to unify', + () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/styles/color_utils.dart' as mui; + + content() { + mui.Button()(); + mui.Checkbox()(); + mui.ButtonColor.success; + mui.useTheme(); + mui.UnifyIcons.expandMore()(); + mui.Button; + mui.Button(); + mui.darken('abc', 1); + } +''', + expectedOutput: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/styles/color_utils.dart' as mui; + + content() { + unify.Button()(); + unify.Checkbox()(); + unify.ButtonColor.success; + unify.useTheme(); + unify.UnifyIcons.expandMore()(); + unify.Button; + unify.Button(); + unify.darken('abc', 1); + } +''', + ); + }); + + test('alpha namespace from react_material_ui is migrated to unify', + () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as alpha_mui; + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as mui_alpha; + + content() { + alpha_mui.Rating()(); + mui_alpha.Rating()(); + alpha_mui.TimelinePosition.left; + alpha_mui.useGridApiRef(); + alpha_mui.Popper; + alpha_mui.Popper(); + mui_alpha.TimelinePosition.left; + mui_alpha.useGridApiRef(); + mui_alpha.Popper; + mui_alpha.Popper(); + } +''', + expectedOutput: /*language=dart*/ ''' + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as alpha_mui; + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as mui_alpha; + + content() { + alpha_unify.Rating()(); + alpha_unify.Rating()(); + alpha_unify.TimelinePosition.left; + alpha_unify.useGridApiRef(); + alpha_unify.Popper; + alpha_unify.Popper(); + alpha_unify.TimelinePosition.left; + alpha_unify.useGridApiRef(); + alpha_unify.Popper; + alpha_unify.Popper(); + } +''', + ); + }); + + test('nested component implementations', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:over_react/over_react.dart'; + + content() { + Fragment()(mui.ButtonToolbar()( + (mui.Button()..color = mui.ButtonColor.wsdBtnLight)('Foo'), + (mui.Button() + ..size = mui.ButtonSize.small + ..color = mui.ButtonColor.primary + )( + 'Bar', + ), + )); + + return (mui.Autocomplete() + ..componentsProps = { + 'popper': mui.Popper()..placement = 'top-end', + 'popupIndicator': mui.IconButton()..sx = {'width': '20px'}, + } + ..sx = { + 'color': (mui.Theme theme) => + mui.getThemePalette(theme).common.white + } + ..renderInput = mui.wrapRenderInput((textFieldProps) => (mui.TextField() + ..addProps(textFieldProps) + ..InputLabelProps = (mui.InputLabel() + ..shrink = false) + )()) + ); + } +''', + expectedOutput: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:over_react/over_react.dart'; + + content() { + Fragment()(unify.ButtonToolbar()( + (unify.Button()..color = unify_wsd.WsdButtonColor.wsdBtnLight)('Foo'), + (unify.Button() + ..size = unify.ButtonSize.small + ..color = unify.ButtonColor.primary + )( + 'Bar', + ), + )); + + return (unify.Autocomplete() + ..componentsProps = { + 'popper': unify.Popper()..placement = 'top-end', + 'popupIndicator': unify.IconButton()..sx = {'width': '20px'}, + } + ..sx = { + 'color': (unify.Theme theme) => + unify.getThemePalette(theme).common.white + } + ..renderInput = unify.wrapRenderInput((textFieldProps) => (unify.TextField() + ..addProps(textFieldProps) + ..InputLabelProps = (unify.InputLabel() + ..shrink = false) + )()) + ); + } +''', + ); + }); + + test('mui namespace from a different package is not migrated', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:over_react/over_react.dart' as mui; + import 'package:over_react/over_react.dart' as alpha_mui; + import 'package:over_react/over_react.dart' as mui_alpha; + + content() { + mui.Fragment()(); + alpha_mui.Fragment()(); + mui_alpha.Fragment()(); + mui.useRef(); + } +''', + ); + }); + + test('non-mui namespace on a react_material_ui import', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as abc; + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as other; + import 'package:react_material_ui/z_alpha_may_break_at_runtime_do_not_release_to_customers.dart' as something; + + content() { + abc.Button()(); + abc.Checkbox()(); + other.Rating; + something.Rating(); + } +''', + ); + }); + + test('no namespace on a react_material_ui import', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart'; + + content() { + Button()(); + Checkbox()(); + } +''', + ); + }); + }); + + group('renames', () { + test('components from react_material_ui to unify equivalents', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/react_material_ui.dart'; + import 'package:react_material_ui/react_material_ui.dart' as random_rmui_namespace; + import 'package:react_material_ui/components/providers/workiva_mui_theme_provider.dart'; + + content() { + mui.Alert()(); + mui.Alert(); + mui.Alert; + mui.AlertSize.small; + AlertSize.small; + AlertSeverity.error; + mui.AlertColor.warning; + mui.AlertVariant.outlined; + Alert()(); + random_rmui_namespace.Alert()(); + mui.LinkButton()(); + LinkButton()(); + random_rmui_namespace.LinkButton()(); + mui.MuiList()(); + MuiList()(); + random_rmui_namespace.MuiList()(); + WorkivaMuiThemeProvider()(); + } +''', + expectedOutput: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/react_material_ui.dart'; + import 'package:react_material_ui/react_material_ui.dart' as random_rmui_namespace; + import 'package:react_material_ui/components/providers/workiva_mui_theme_provider.dart'; + + content() { + unify_wsd.WsdAlert()(); + unify_wsd.WsdAlert(); + unify_wsd.WsdAlert; + unify_wsd.WsdAlertSize.small; + unify_wsd.WsdAlertSize.small; + unify_wsd.WsdAlertSeverity.error; + unify_wsd.WsdAlertColor.warning; + unify_wsd.WsdAlertVariant.outlined; + unify_wsd.WsdAlert()(); + unify_wsd.WsdAlert()(); + unify_wsd.WsdLinkButton()(); + unify_wsd.WsdLinkButton()(); + unify_wsd.WsdLinkButton()(); + unify.UnifyList()(); + UnifyList()(); + random_rmui_namespace.UnifyList()(); + UnifyThemeProvider()(); + } +''', + ); + }); + + test('objects from react_material_ui to unify equivalents', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/react_material_ui.dart'; + import 'package:react_material_ui/react_material_ui.dart' as random_rmui_namespace; + + content() { + mui.AutocompleteOnChangeObject; + mui.AutocompleteOnChangeObject(); + random_rmui_namespace.BackdropTimeoutObject; + BadgeAnchorOriginObjectHorizontal; + MenuAnchorOriginObject(); + (Slider()..marks = [SliderMarksObject(value: 10)])(); + } +''', + expectedOutput: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/react_material_ui.dart'; + import 'package:react_material_ui/react_material_ui.dart' as random_rmui_namespace; + + content() { + unify.AutocompleteChangeDetails; + unify.AutocompleteChangeDetails(); + random_rmui_namespace.BackdropObject; + BadgeOriginHorizontal; + MenuPopoverOrigin(); + (Slider()..marks = [SliderMark(value: 10)])(); + } +''', + ); + }); + + test('ButtonColor updates', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/react_material_ui.dart'; + + content() { + mui.ButtonColor.success; + mui.ButtonColor.wsdBtnInverse; + mui.ButtonColor.wsdBtnLight; + mui.ButtonColor.wsdBtnWhite; + ButtonColor.success; + ButtonColor.wsdBtnInverse; + ButtonColor.wsdBtnLight; + ButtonColor.wsdBtnWhite; + } +''', + expectedOutput: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/react_material_ui.dart'; + + content() { + unify.ButtonColor.success; + unify_wsd.WsdButtonColor.wsdBtnInverse; + unify_wsd.WsdButtonColor.wsdBtnLight; + unify_wsd.WsdButtonColor.wsdBtnWhite; + ButtonColor.success; + unify_wsd.WsdButtonColor.wsdBtnInverse; + unify_wsd.WsdButtonColor.wsdBtnLight; + unify_wsd.WsdButtonColor.wsdBtnWhite; + } +''', + ); + }); + + test('except when they are not from react_material_ui', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:over_react/over_react.dart'; + + // Shadows the RMUI factories + UiFactory Alert; + UiFactory LinkButton; + UiFactory MuiList; + UiFactory WorkivaMuiThemeProvider; + class BackdropTimeoutObject {} + abstract class ButtonColor { + static const String wsdBtnLight = 'wsdBtnLight'; + } + + content() { + Alert()(); + LinkButton()(); + MuiList()(); + WorkivaMuiThemeProvider()(); + BackdropTimeoutObject; + ButtonColor.wsdBtnLight; + } +''', + ); + }); + }); + + group('fixme comments', () { + test('for specific components that need manual intervention', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/react_material_ui.dart'; + + content() { + mui.Badge()(); + Badge()(); + mui.LinearProgress()(); + LinearProgress()(); + } +''', + expectedOutput: /*language=dart*/ ''' + import 'package:react_material_ui/react_material_ui.dart' as mui; + import 'package:react_material_ui/react_material_ui.dart'; + + content() { + // FIXME(unify_package_rename) Check what theme provider is wrapping this component: if it is a UnifyThemeProvider, remove this FIXME - no action is required; otherwise, migrate this component back to Web Skin Dart. + unify.Badge()(); + // FIXME(unify_package_rename) Check what theme provider is wrapping this component: if it is a UnifyThemeProvider, remove this FIXME - no action is required; otherwise, migrate this component back to Web Skin Dart. + Badge()(); + // FIXME(unify_package_rename) Check what theme provider is wrapping this component: if it is a UnifyThemeProvider, remove this FIXME - no action is required; otherwise, migrate this component back to Web Skin Dart. + unify.LinearProgress()(); + // FIXME(unify_package_rename) Check what theme provider is wrapping this component: if it is a UnifyThemeProvider, remove this FIXME - no action is required; otherwise, migrate this component back to Web Skin Dart. + LinearProgress()(); + } +''', + ); + }); + + test('except when they are not from react_material_ui', () async { + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:over_react/over_react.dart'; + + // Shadows the RMUI factories + UiFactory Badge; + UiFactory LinearProgress; + + content() { + Badge()(); + LinearProgress()(); + } +''', + ); + }); + }); + // Run in private Skynet unit tests with wsd-related tests. + }, tags: 'wsd'); +} diff --git a/test/mui_suggestors/mui_importer_test.dart b/test/util/importer_test.dart similarity index 92% rename from test/mui_suggestors/mui_importer_test.dart rename to test/util/importer_test.dart index 7a42914c..2be1af36 100644 --- a/test/mui_suggestors/mui_importer_test.dart +++ b/test/util/importer_test.dart @@ -13,15 +13,18 @@ // limitations under the License. import 'package:analyzer/error/error.dart'; -import 'package:over_react_codemod/src/mui_suggestors/mui_importer.dart'; +import 'package:over_react_codemod/src/mui_suggestors/constants.dart'; +import 'package:over_react_codemod/src/util/importer.dart'; import 'package:test/test.dart'; import '../resolved_file_context.dart'; import '../util.dart'; void main() { - group('muiImporter', () { + group('importerSuggestorBuilder', () { final resolvedContext = SharedAnalysisContext.overReact; + final muiImporter = importerSuggestorBuilder( + importUri: rmuiImportUri, importNamespace: muiNs); // 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. @@ -350,6 +353,28 @@ void main() { ''', ); }); + + test('for a different package name', () async { + final testSuggestor = getSuggestorTester( + importerSuggestorBuilder( + importUri: 'package:over_react/over_react.dart', + importNamespace: 'or'), + resolvedContext: resolvedContext, + ); + await testSuggestor( + input: /*language=dart*/ ''' + + content() => or.Fragment(); + ''', + isExpectedError: (error) => + error.message.contains("Undefined name 'or'"), + expectedOutput: /*language=dart*/ ''' + import 'package:over_react/over_react.dart' as or; + + content() => or.Fragment(); + ''', + ); + }); }); }); } diff --git a/test/mui_suggestors/unused_wsd_import_remover_test.dart b/test/util/unused_import_remover_test.dart similarity index 81% rename from test/mui_suggestors/unused_wsd_import_remover_test.dart rename to test/util/unused_import_remover_test.dart index 6e84f340..2b2575ef 100644 --- a/test/mui_suggestors/unused_wsd_import_remover_test.dart +++ b/test/util/unused_import_remover_test.dart @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:over_react_codemod/src/mui_suggestors/unused_wsd_import_remover.dart'; +import 'package:over_react_codemod/src/util/unused_import_remover.dart'; import 'package:test/test.dart'; import '../resolved_file_context.dart'; @@ -27,7 +27,7 @@ void main() { setUpAll(resolvedContext.warmUpAnalysis); final testSuggestor = getSuggestorTester( - unusedWsdImportRemover, + unusedImportRemoverSuggestorBuilder('web_skin_dart'), resolvedContext: resolvedContext, ); @@ -124,6 +124,28 @@ void main() { ''', ); }); + + test('for a different package name', () async { + final testSuggestor = getSuggestorTester( + unusedImportRemoverSuggestorBuilder('over_react'), + resolvedContext: resolvedContext, + ); + await testSuggestor( + input: /*language=dart*/ ''' + import 'package:over_react/over_react.dart'; + import 'package:web_skin_dart/ui_components.dart'; + import 'package:web_skin_dart/component2/all.dart' as wsd2; + + content() => wsd2.Button()(); + ''', + expectedOutput: /*language=dart*/ ''' + import 'package:web_skin_dart/ui_components.dart'; + import 'package:web_skin_dart/component2/all.dart' as wsd2; + + content() => wsd2.Button()(); + ''', + ); + }); }); }, tags: 'wsd'); }