diff --git a/analysis_options.yaml b/analysis_options.yaml index fab2e96..674d744 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,7 +1,8 @@ -# analysis_options.yaml docs: https://www.dartlang.org/guides/language/analysis-options +# analysis_options.yaml docs: https://www.dartlang.org/guides/language/analysis-options analyzer: exclude: - "example/**" + - "w_flux_codemod/**" language: strict-casts: true @@ -712,5 +713,3 @@ linter: # reason: Trying to assigning a value to void is an error. # 0 issues - void_checks - - diff --git a/dart_dependency_validator.yaml b/dart_dependency_validator.yaml index 91c26d3..c039c0d 100644 --- a/dart_dependency_validator.yaml +++ b/dart_dependency_validator.yaml @@ -1,5 +1,6 @@ exclude: - "example/**" + - "w_flux_codemod/**" ignore: # Ignore the pin on the test package while we have to avoid a bad version of test 1.18.1 https://github.com/dart-lang/test/issues/1620 - - test \ No newline at end of file + - test diff --git a/w_flux_codemod/README.md b/w_flux_codemod/README.md new file mode 100644 index 0000000..521a487 --- /dev/null +++ b/w_flux_codemod/README.md @@ -0,0 +1,91 @@ +# w_flux_codemod + +> **Built with [dart_codemod][dart_codemod].** + +A codemod to convert existing usages of non null-safe `Action` to null-safe `ActionV2`. + +## Motivation + +`w_flux` was upgraded to dart 3 and made null safe, but we ran into an issue when migrating the `Action` class. + +The `Action` class has a call method with an optional `payload` paramater that now must be typed as nullable. However, this means that we cannot make `listener` payloads non-nullable, since there's no guarantee that the argument was specified. + +``` +class Action /*...*/ { + Future call([T? payload]) { + for (final listener in _listeners) { + await listener(payload); + // ^^^^^^^ + // Error: can't assign T? to T + } + } + + ActionSubscription listen(dynamic onData(T event)) {/*...*/} + + /*...*/ +} +``` + +To be able to support non-nullable payloads (in addition to nullable payloads), we made a new `ActionV2` class with required payloads. + +## Usage + +1. Ensure you have the codemod package installed. + ```bash + dart pub global activate -sgit git@github.com:Workiva/w_flux.git --git-path=w_flux_codemod + ``` + +2. Run the codemod: + + - step by step: + ```bash + dart pub global run w_flux_codemod:action_v2_migrate_step_1 --yes-to-all + dart pub global run w_flux_codemod:action_v2_migrate_step_2 --yes-to-all + ``` + + - all at once: + ```bash + dart pub global run w_flux_codemod:action_v2_migrate --yes-to-all + ``` + + - The optional command `--yes-to-all` will automatically accept all changes. You can exclude this command to go through every change one by one. + +3. Review the changes: + + - It's advisable to review the changes and ensure they are correct and meet your project's requirements. + - This codemod is not gauranteed to catch every implementation of `Action` and convert to `ActionV2`. For example: assigning `Action` to prop in a callback will be missed by this codemod. + - Dart Analysis should be able to catch anything missed or errors caused by the codemod, and a passing CI should suffice for QA when making these updates. + + +## Example + +Before codemod: + +```dart +import 'package:w_flux/w_flux.dart'; + +class C { + Action action; +} + +void main() { + C().action(); +} +``` + +After codemod: + +```dart +import 'package:w_flux/w_flux.dart'; + +class C { + ActionV2 action; +} + +void main() { + // A payload is required for ActionV2, so `null` is added when needed. + C().action(null); +} +``` + +[dart_codemod]: https://github.com/Workiva/dart_codemod \ No newline at end of file diff --git a/w_flux_codemod/analysis_options.yaml b/w_flux_codemod/analysis_options.yaml new file mode 100644 index 0000000..a67a6c8 --- /dev/null +++ b/w_flux_codemod/analysis_options.yaml @@ -0,0 +1 @@ +include: package:workiva_analysis_options/v2.yaml diff --git a/w_flux_codemod/bin/action_v2_migrate.dart b/w_flux_codemod/bin/action_v2_migrate.dart new file mode 100644 index 0000000..2dd7f2e --- /dev/null +++ b/w_flux_codemod/bin/action_v2_migrate.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:codemod/codemod.dart'; +import 'package:glob/glob.dart'; +import 'package:w_flux_codemod/src/action_v2_suggestor.dart'; +import 'package:w_flux_codemod/src/utils.dart'; + +void main(List args) async { + final dartPaths = filePathsFromGlob(Glob('**.dart', recursive: true)); + + await pubGetForAllPackageRoots(dartPaths); + exitCode = await runInteractiveCodemod( + dartPaths, + aggregate([ + ActionV2ParameterMigrator(), + ActionV2FieldAndVariableMigrator(), + ActionV2ReturnTypeMigrator(), + ActionV2SuperTypeMigrator(), + ActionV2DispatchMigrator(), + ActionV2ImportMigrator(), + ]), + args: args, + ); +} diff --git a/w_flux_codemod/bin/action_v2_migrate_step_1.dart b/w_flux_codemod/bin/action_v2_migrate_step_1.dart new file mode 100644 index 0000000..90605d1 --- /dev/null +++ b/w_flux_codemod/bin/action_v2_migrate_step_1.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:codemod/codemod.dart'; +import 'package:glob/glob.dart'; +import 'package:w_flux_codemod/src/action_v2_suggestor.dart'; +import 'package:w_flux_codemod/src/utils.dart'; + +void main(List args) async { + final dartPaths = filePathsFromGlob(Glob('**.dart', recursive: true)); + + await pubGetForAllPackageRoots(dartPaths); + exitCode = await runInteractiveCodemod( + dartPaths, + ActionV2ParameterMigrator(), + args: args, + ); +} diff --git a/w_flux_codemod/bin/action_v2_migrate_step_2.dart b/w_flux_codemod/bin/action_v2_migrate_step_2.dart new file mode 100644 index 0000000..0739707 --- /dev/null +++ b/w_flux_codemod/bin/action_v2_migrate_step_2.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:codemod/codemod.dart'; +import 'package:glob/glob.dart'; +import 'package:w_flux_codemod/src/action_v2_suggestor.dart'; +import 'package:w_flux_codemod/src/utils.dart'; + +void main(List args) async { + final dartPaths = filePathsFromGlob(Glob('**.dart', recursive: true)); + + await pubGetForAllPackageRoots(dartPaths); + exitCode = await runInteractiveCodemod( + dartPaths, + aggregate([ + ActionV2FieldAndVariableMigrator(), + ActionV2ReturnTypeMigrator(), + ActionV2SuperTypeMigrator(), + ActionV2DispatchMigrator(), + ActionV2ImportMigrator(), + ]), + args: args, + ); +} diff --git a/w_flux_codemod/lib/src/action_v2_suggestor.dart b/w_flux_codemod/lib/src/action_v2_suggestor.dart new file mode 100644 index 0000000..874cb0c --- /dev/null +++ b/w_flux_codemod/lib/src/action_v2_suggestor.dart @@ -0,0 +1,127 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:codemod/codemod.dart'; + +mixin ActionV2Migrator on AstVisitingSuggestor { + @override + bool shouldResolveAst(_) => true; + + @override + bool shouldSkip(FileContext context) => + !context.sourceText.contains('Action'); +} + +mixin ActionV2NamedTypeMigrator on ActionV2Migrator { + bool shouldMigrate(NamedType node); + + @override + visitNamedType(NamedType node) { + if (shouldMigrate(node)) { + final typeNameToken = node.name2; + final typeLibraryIdentifier = node.element?.library?.identifier ?? ''; + if (typeNameToken.lexeme == 'Action' && + typeLibraryIdentifier.startsWith('package:w_flux/')) { + yieldPatch('ActionV2', typeNameToken.offset, typeNameToken.end); + } + return super.visitNamedType(node); + } + } +} + +class ActionV2ImportMigrator extends RecursiveAstVisitor + with AstVisitingSuggestor, ActionV2Migrator { + @override + visitShowCombinator(ShowCombinator node) { + final parent = node.parent; + if (parent is ImportDirective) { + final uri = parent.uri.stringValue; + final shownNamesList = node.shownNames.map((id) => id.name); + if (uri != null && + uri.startsWith('package:w_flux/') && + shownNamesList.contains('Action')) { + final updatedNamesList = + shownNamesList.map((name) => name == 'Action' ? 'ActionV2' : name); + yieldPatch('${node.keyword} ${updatedNamesList.join(', ')}', + node.offset, node.end); + } + } + return super.visitShowCombinator(node); + } + + @override + visitHideCombinator(HideCombinator node) { + final parent = node.parent; + if (parent is ImportDirective) { + final uri = parent.uri.stringValue; + final hiddenNamesList = node.hiddenNames.map((id) => id.name); + if (uri != null && + uri.startsWith('package:w_flux/') && + hiddenNamesList.contains('Action')) { + final updatedNamesList = hiddenNamesList + .map((name) => name == 'Action' ? 'ActionV2' : name) + .join(', '); + yieldPatch('${node.keyword} $updatedNamesList', node.offset, node.end); + } + } + return super.visitHideCombinator(node); + } +} + +class ActionV2DispatchMigrator extends RecursiveAstVisitor + with AstVisitingSuggestor, ActionV2Migrator { + @override + visitFunctionExpressionInvocation(FunctionExpressionInvocation node) { + final typeLibraryIdentifier = + node.function.staticType?.element?.library?.identifier ?? ''; + final staticTypeName = node.function.staticType?.element?.name; + if (typeLibraryIdentifier.startsWith('package:w_flux/') && + staticTypeName == 'Action' && + node.argumentList.arguments.isEmpty) { + yieldPatch('(null)', node.end - 2, node.end); + } + return super.visitFunctionExpressionInvocation(node); + } +} + +class ActionV2FieldAndVariableMigrator extends RecursiveAstVisitor + with AstVisitingSuggestor, ActionV2Migrator, ActionV2NamedTypeMigrator { + @override + bool shouldMigrate(node) => + node.thisOrAncestorOfType() == null && + (node.parent is DeclaredIdentifier || + node.parent is DeclaredVariablePattern || + node.parent is FieldFormalParameter || + node.parent is VariableDeclarationList || + node.parent is TypeArgumentList || + node.thisOrAncestorOfType() != null || + node.thisOrAncestorOfType() != null); +} + +class ActionV2ParameterMigrator extends RecursiveAstVisitor + with AstVisitingSuggestor, ActionV2Migrator, ActionV2NamedTypeMigrator { + @override + bool shouldMigrate(node) => + node.thisOrAncestorOfType() != null && + node.thisOrAncestorOfType() == null && + node.thisOrAncestorOfType() == null; +} + +class ActionV2ReturnTypeMigrator extends RecursiveAstVisitor + with AstVisitingSuggestor, ActionV2Migrator, ActionV2NamedTypeMigrator { + @override + shouldMigrate(node) => + node.parent is FunctionDeclaration || + node.parent is FunctionTypeAlias || + node.parent is GenericFunctionType || + node.thisOrAncestorOfType() != null; +} + +class ActionV2SuperTypeMigrator extends RecursiveAstVisitor + with AstVisitingSuggestor, ActionV2Migrator, ActionV2NamedTypeMigrator { + @override + bool shouldMigrate(node) => + node.parent is ExtendsClause || + node.parent is ImplementsClause || + node.parent is OnClause || + node.parent is TypeParameter; +} diff --git a/w_flux_codemod/lib/src/utils.dart b/w_flux_codemod/lib/src/utils.dart new file mode 100644 index 0000000..b5e60bd --- /dev/null +++ b/w_flux_codemod/lib/src/utils.dart @@ -0,0 +1,130 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; + +final _logger = Logger('w_flux_codemod.pubspec'); + +Future pubGetForAllPackageRoots(Iterable files) async { + _logger.info( + 'Running `pub get` if needed so that all Dart files can be resolved...'); + final packageRoots = files.map(findPackageRootFor).toSet(); + for (final packageRoot in packageRoots) { + await runPubGetIfNeeded(packageRoot); + } +} + +bool _isPubGetNecessary(String packageRoot) { + final packageConfig = + File(p.join(packageRoot, '.dart_tool', 'package_config.json')); + final pubspec = File(p.join(packageRoot, 'pubspec.yaml')); + final pubspecLock = File(p.join(packageRoot, 'pubspec.lock')); + + if (!pubspec.existsSync()) { + throw ArgumentError('pubspec.yaml not found in directory: $packageRoot'); + } + + if (packageConfig.existsSync() && pubspecLock.existsSync()) { + return !pubspecLock.lastModifiedSync().isAfter(pubspec.lastModifiedSync()); + } + + return true; +} + +/// Runs `pub get` in [packageRoot] unless running `pub get` would have no effect. +Future runPubGetIfNeeded(String packageRoot) async { + if (_isPubGetNecessary(packageRoot)) { + await runPubGet(packageRoot); + } else { + _logger.info( + 'Skipping `dart pub get`, which has already been run, in `$packageRoot`'); + } +} + +/// Runs `dart pub get` in [workingDirectory], and throws if the command +/// completed with a non-zero exit code. +/// +/// For convenience, tries running with `dart pub get --offline` if `pub get` +/// fails, for a better experience when not authenticated to private pub servers. +Future runPubGet(String workingDirectory) async { + _logger.info('Running `dart pub get` in `$workingDirectory`...'); + + final process = await Process.start('dart', ['pub', 'get'], + workingDirectory: workingDirectory, + runInShell: true, + mode: ProcessStartMode.inheritStdio); + final exitCode = await process.exitCode; + + if (exitCode == 69) { + _logger.info( + 'Re-running `dart pub get` but with `--offline`, to hopefully fix the above error.'); + final process = await Process.start('dart', ['pub', 'get', '--offline'], + workingDirectory: workingDirectory, + runInShell: true, + mode: ProcessStartMode.inheritStdio); + final exitCode = await process.exitCode; + if (exitCode != 0) { + throw Exception('dart pub get failed with exit code: $exitCode'); + } + } else if (exitCode != 0) { + throw Exception('dart pub get failed with exit code: $exitCode'); + } +} + +/// Returns a path to the closest Dart package root (i.e., a directory with a +/// pubspec.yaml file) to [path], throwing if no package root can be found. +/// +/// If [path] is itself a package root, it will be returned. +/// +/// Example: +/// +/// ```dart +/// // All of these return a path to 'some_package' +/// findPackageRootFor('some_package/lib/src/file.dart'); +/// findPackageRootFor('some_package/lib/'); +/// findPackageRootFor('some_package'); +/// +/// // Returns a path to 'some_package/subpackages/some_nested_package' +/// findPackageRootFor('some_package/some_nested_package/lib/file.dart'); +/// ``` +String findPackageRootFor(String path) { + final packageRoot = [ + path, + ...ancestorsOfPath(path) + ].firstWhereOrNull((path) => File(p.join(path, 'pubspec.yaml')).existsSync()); + + if (packageRoot == null) { + throw Exception('Could not find package root for file `$path`'); + } + + return packageRoot; +} + +/// Returns canonicalized paths for all the the ancestor directories of [path], +/// starting with its parent and working upwards. +Iterable ancestorsOfPath(String path) sync* { + path = p.canonicalize(path); + + // p.dirname of the root directory is the root directory, so if they're the same, stop. + final parent = p.dirname(path); + if (p.equals(path, parent)) return; + + yield parent; + yield* ancestorsOfPath(parent); +} + +/// Returns whether [file] is within a top-level `build` directory of a package root. +bool isNotWithinTopLevelBuildOutputDir(File file) => + !isWithinTopLevelDir(file, 'build'); + +/// Returns whether [file] is within a top-level `tool` directory of a package root. +bool isNotWithinTopLevelToolDir(File file) => + !isWithinTopLevelDir(file, 'tool'); + +/// Returns whether [file] is within a top-level [topLevelDir] directory +/// (e.g., `bin`, `lib`, `web`) of a package root. +bool isWithinTopLevelDir(File file, String topLevelDir) => + ancestorsOfPath(file.path).any((ancestor) => + p.basename(ancestor) == topLevelDir && + File(p.join(p.dirname(ancestor), 'pubspec.yaml')).existsSync()); diff --git a/w_flux_codemod/pubspec.yaml b/w_flux_codemod/pubspec.yaml new file mode 100644 index 0000000..017eb57 --- /dev/null +++ b/w_flux_codemod/pubspec.yaml @@ -0,0 +1,24 @@ +name: w_flux_codemod +version: 1.0.0 +description: codemod for migrating to ActionV2 +homepage: https://github.com/Workiva/w_flux/tree/master/w_flux_codemod + +environment: + sdk: ">=2.19.0 <3.0.0" + +executables: + action_v2_migrate: + action_v2_migrate_step_1: + action_v2_migrate_step_2: + +dependencies: + analyzer: ^5.13.0 + codemod: ^1.2.0 + logging: ^1.0.1 + glob: ^2.1.2 + workiva_analysis_options: ^1.3.0 + +dev_dependencies: + meta: ^1.11.0 + source_span: ^1.10.0 + test: ^1.24.3 diff --git a/w_flux_codemod/test/action_v2_suggestor_test.dart b/w_flux_codemod/test/action_v2_suggestor_test.dart new file mode 100644 index 0000000..28f4f51 --- /dev/null +++ b/w_flux_codemod/test/action_v2_suggestor_test.dart @@ -0,0 +1,470 @@ +import 'package:codemod/codemod.dart'; +import 'package:codemod/test.dart'; +import 'package:meta/meta.dart'; +import 'package:test/test.dart'; +import 'package:w_flux_codemod/src/action_v2_suggestor.dart'; + +const pubspec = ''' +name: pkg +publish_to: none +environment: + sdk: '>=2.11.0 <4.0.0' +dependencies: + w_flux: ^3.0.0 +'''; + +String wFluxImport(WFluxImportMode mode) { + switch (mode) { + case WFluxImportMode.none: + return ''; + case WFluxImportMode.standard: + return "import 'package:w_flux/w_flux.dart';"; + case WFluxImportMode.prefixed: + return "import 'package:w_flux/w_flux.dart' as w_flux;"; + case WFluxImportMode.shown: + return "import 'package:w_flux/w_flux.dart' show Action;"; + case WFluxImportMode.multipleShown: + return "import 'package:w_flux/w_flux.dart' show Action, FluxComponent;"; + case WFluxImportMode.hidden: + return "import 'package:w_flux/w_flux.dart' hide Action;"; + } +} + +String wFluxMigratedImport(WFluxImportMode mode) { + switch (mode) { + case WFluxImportMode.none: + case WFluxImportMode.standard: + case WFluxImportMode.prefixed: + return wFluxImport(mode); + case WFluxImportMode.shown: + return "import 'package:w_flux/w_flux.dart' show ActionV2;"; + case WFluxImportMode.multipleShown: + return "import 'package:w_flux/w_flux.dart' show ActionV2, FluxComponent;"; + case WFluxImportMode.hidden: + return "import 'package:w_flux/w_flux.dart' hide ActionV2;"; + } +} + +enum WFluxImportMode { + none, // don't import w_flux + standard, // import w_flux + prefixed, // import w_flux with a prefix + shown, // import but just show Action + multipleShown, + hidden, // hide from w_flux +} + +void main() { + group('ActionV2 suggestors', () { + late PackageContextForTest pkg; + + setUpAll(() async { + pkg = await PackageContextForTest.fromPubspec(pubspec); + }); + + @isTest + void testSuggestor(String description, Suggestor Function() suggestor, + String before, String after, + {WFluxImportMode importMode = WFluxImportMode.standard, + migrateImport = false}) { + test(description, () async { + final context = await pkg.addFile(''' + ${wFluxImport(importMode)} + ${before} + '''); + final expectedOutput = ''' + ${migrateImport ? wFluxMigratedImport(importMode) : wFluxImport(importMode)} + ${after} + '''; + expectSuggestorGeneratesPatches( + suggestor(), + context, + expectedOutput, + ); + }); + } + + group('ActionV2ImportMigrator', () { + Suggestor suggestor() => ActionV2ImportMigrator(); + + testSuggestor( + 'shown import', + suggestor, + '', + '', + importMode: WFluxImportMode.shown, + migrateImport: true, + ); + + testSuggestor( + 'multiple shown import', + suggestor, + '', + '', + importMode: WFluxImportMode.multipleShown, + migrateImport: true, + ); + + testSuggestor( + 'hidden import', + suggestor, + '', + '', + importMode: WFluxImportMode.hidden, + migrateImport: true, + ); + }); + + group('ActionV2DispatchMigrator', () { + Suggestor suggestor() => ActionV2DispatchMigrator(); + + group( + 'test each import type for the dispatch migrator - local variable invocation', + () { + testSuggestor( + 'standard import', + suggestor, + 'void main() { var a = Action(); a(); }', + 'void main() { var a = Action(); a(null); }', + ); + testSuggestor( + 'import prefix', + suggestor, + 'void main() { var a = w_flux.Action(); a(); }', + 'void main() { var a = w_flux.Action(); a(null); }', + importMode: WFluxImportMode.prefixed, + ); + // the following 3 tests use the "output" import statement because those + // import statements should have been migrated by the other suggestors. + testSuggestor( + 'shown import', + suggestor, + 'void main() { var a = Action(); a(); }', + 'void main() { var a = Action(); a(null); }', + importMode: WFluxImportMode.shown, + ); + testSuggestor( + 'multiple shown imports', + suggestor, + 'void main() { var a = Action(); a(); }', + 'void main() { var a = Action(); a(null); }', + importMode: WFluxImportMode.multipleShown, + ); + testSuggestor( + 'ignores types when hidden from w_flux', + suggestor, + 'void main() { var a = Action(); a(); }', + 'void main() { var a = Action(); a(); }', + importMode: WFluxImportMode.hidden, + ); + testSuggestor( + 'ignores types not from w_flux', + suggestor, + 'class Action { call(); } void main() { var a = Action(); a(); }', + 'class Action { call(); } void main() { var a = Action(); a(); }', + importMode: WFluxImportMode.none, + ); + }); + testSuggestor( + 'local invocation of field', + suggestor, + 'class C { Action action; dispatch() => action(); }', + 'class C { Action action; dispatch() => action(null); }', + ); + testSuggestor( + 'external invocation of field', + suggestor, + 'class C { Action action; } void main() { C().action(); }', + 'class C { Action action; } void main() { C().action(null); }', + ); + testSuggestor( + 'nested dispatch', + suggestor, + ''' + class A { final Action action = Action(); } + class B { final actions = A(); } + void main() { + B().actions.action(); + } + ''', + ''' + class A { final Action action = Action(); } + class B { final actions = A(); } + void main() { + B().actions.action(null); + } + ''', + ); + }); + + group('FieldAndVariableMigrator', () { + Suggestor suggestor() => ActionV2FieldAndVariableMigrator(); + group('VariableDeclarationList', () { + // test each import type on a named type migrator + group('each import variation for type, with no intializer', () { + testSuggestor( + 'standard import', + suggestor, + 'Action action;', + 'ActionV2 action;', + ); + testSuggestor( + 'prefixed import', + suggestor, + 'w_flux.Action action;', + 'w_flux.ActionV2 action;', + importMode: WFluxImportMode.prefixed, + ); + testSuggestor( + 'ignore Action when hidden from w_flux', + suggestor, + 'Action action;', + 'Action action;', + importMode: WFluxImportMode.hidden, + ); + testSuggestor( + 'ignore types not from w_flux', + suggestor, + 'Action action;', + 'Action action;', + importMode: WFluxImportMode.none, + ); + }); + testSuggestor( + 'with type and intializer', + suggestor, + 'Action action = Action();', + 'ActionV2 action = ActionV2();', + ); + testSuggestor( + 'no type, with intializer', + suggestor, + 'var action = Action();', + 'var action = ActionV2();', + ); + testSuggestor( + 'dynamic Actions', + suggestor, + 'Action a; Action b = Action(); var c = Action();', + 'ActionV2 a; ActionV2 b = ActionV2(); var c = ActionV2();', + ); + testSuggestor( + 'nested type', + suggestor, + 'List> actions = [Action(), Action()];', + 'List> actions = [ActionV2(), ActionV2()];', + ); + }); + group('FieldDeclaration', () { + testSuggestor( + 'with type, no intializer', + suggestor, + 'class C { Action action; }', + 'class C { ActionV2 action; }', + ); + testSuggestor( + 'with type and intializer', + suggestor, + 'class C { Action action = Action(); }', + 'class C { ActionV2 action = ActionV2(); }', + ); + testSuggestor( + 'no type, with intializer', + suggestor, + 'class C { var action = Action(); }', + 'class C { var action = ActionV2(); }', + ); + testSuggestor( + 'dynamic Actions', + suggestor, + 'class C { Action a; Action b = Action(); var c = Action(); }', + 'class C { ActionV2 a; ActionV2 b = ActionV2(); var c = ActionV2(); }', + ); + // List is a return type and will not be modified in this suggestor + testSuggestor( + 'nested Action type', + suggestor, + ''' + abstract class Actions { + final Action openAction = Action(); + final Action closeAction = Action(); + + List get actions => [ + openAction, + closeAction, + ]; + } + ''', + ''' + abstract class Actions { + final ActionV2 openAction = ActionV2(); + final ActionV2 closeAction = ActionV2(); + + List get actions => [ + openAction, + closeAction, + ]; + } + ''', + ); + }); + group('InstanceCreationExpression', () { + testSuggestor( + 'variable initialization', + suggestor, + 'var action; action = Action();', + 'var action; action = ActionV2();', + ); + testSuggestor( + 'field initialization', + suggestor, + 'class C { var action; C() { action = Action(); } }', + 'class C { var action; C() { action = ActionV2(); } }', + ); + testSuggestor( + 'function initialization', + suggestor, + 'Action a; Action b = Action(); var c = Action();', + 'ActionV2 a; ActionV2 b = ActionV2(); var c = ActionV2();', + ); + }); + }); + + group('ParameterMigrator', () { + Suggestor suggestor() => ActionV2ParameterMigrator(); + testSuggestor( + 'SimpleFormalParameter.type (function)', + suggestor, + 'fn(Action action) {}', + 'fn(ActionV2 action) {}', + ); + testSuggestor( + 'Parameter type with a generic', + suggestor, + 'fn(Action action) {}', + 'fn(ActionV2 action) {}', + ); + }); + + group('ReturnTypeMigrator', () { + Suggestor suggestor() => ActionV2ReturnTypeMigrator(); + + testSuggestor( + 'FunctionDeclaration.returnType', + suggestor, + 'Action fn() {}', + 'ActionV2 fn() {}', + ); + testSuggestor( + 'FunctionTypeAlias.returnType', + suggestor, + 'typedef t = Action Function();', + 'typedef t = ActionV2 Function();', + ); + testSuggestor( + 'FunctionTypedFormalParameter.returnType (function)', + suggestor, + 'fn(Action Function() fn) {}', + 'fn(ActionV2 Function() fn) {}', + ); + testSuggestor( + 'FunctionTypedFormalParameter.returnType (method)', + suggestor, + 'class C { m(Action Function() fn) {} }', + 'class C { m(ActionV2 Function() fn) {} }', + ); + testSuggestor( + 'GenericFunctionType.returnType', + suggestor, + 'fn(Action Function() callback) {}', + 'fn(ActionV2 Function() callback) {}', + ); + testSuggestor( + 'MethodDeclaration.returnType', + suggestor, + 'class C { Action m() {} }', + 'class C { ActionV2 m() {} }', + ); + + testSuggestor( + 'MethodDeclaration SimpleFormalParameter.type', + suggestor, + 'class C { m(Action action) {} }', + 'class C { m(ActionV2 action) {} }', + ); + testSuggestor( + 'Return type with a generic', + suggestor, + 'Action fn() {}', + 'ActionV2 fn() {}', + ); + // field declarations (final Action) should not be migrated in this codemod + testSuggestor( + 'nested return type', + suggestor, + ''' + abstract class Actions { + final Action openAction = Action(); + final Action closeAction = Action(); + + List get actions => [ + openAction, + closeAction, + ]; + } + ''', + ''' + abstract class Actions { + final Action openAction = Action(); + final Action closeAction = Action(); + + List get actions => [ + openAction, + closeAction, + ]; + } + ''', + ); + }); + + group('TypeParameterMigrator', () { + Suggestor suggestor() => ActionV2SuperTypeMigrator(); + testSuggestor( + 'standard import', + suggestor, + 'class C extends Action {}', + 'class C extends ActionV2 {}', + ); + testSuggestor( + 'ExtendsClause.superclass', + suggestor, + 'class C extends Action {}', + 'class C extends ActionV2 {}', + ); + testSuggestor( + 'ImplementsClause.interfaces', + suggestor, + 'class C implements Action {}', + 'class C implements ActionV2 {}', + ); + testSuggestor( + 'OnClause.superclassConstraints', + suggestor, + 'class C extends Action {}', + 'class C extends ActionV2 {}', + ); + testSuggestor( + 'Type parameter', + suggestor, + 'fn() {}', + 'fn() {}', + ); + testSuggestor( + 'Type parameter with a generic', + suggestor, + 'fn>() {}', + 'fn>() {}', + ); + }); + }); +}