diff --git a/.gitignore b/.gitignore index ca535ca..a8d408f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .dart_tool/ .idea/ .packages -pubspec.lock \ No newline at end of file +pubspec.lock +.history +codemod/.failed_tracker +codemod_core/.failed_tracker diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..244a95c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,32 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "codemod", + "cwd": "codemod", + "request": "launch", + "type": "dart" + }, + { + "name": "codemod_core", + "cwd": "codemod_core", + "request": "launch", + "type": "dart" + }, + { + "name": "regex_substituter_fixtures", + "cwd": "codemod/example/regex_substituter_fixtures", + "request": "launch", + "type": "dart" + }, + { + "name": "before", + "cwd": "codemod/test_fixtures/functional/before", + "request": "launch", + "type": "dart" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/codemod/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to codemod/CHANGELOG.md diff --git a/CONTRIBUTING.md b/codemod/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to codemod/CONTRIBUTING.md diff --git a/Dockerfile b/codemod/Dockerfile similarity index 100% rename from Dockerfile rename to codemod/Dockerfile diff --git a/LICENSE b/codemod/LICENSE similarity index 100% rename from LICENSE rename to codemod/LICENSE diff --git a/NOTICE b/codemod/NOTICE similarity index 100% rename from NOTICE rename to codemod/NOTICE diff --git a/README.md b/codemod/README.md similarity index 100% rename from README.md rename to codemod/README.md diff --git a/analysis_options.yaml b/codemod/analysis_options.yaml similarity index 100% rename from analysis_options.yaml rename to codemod/analysis_options.yaml diff --git a/aviary.yaml b/codemod/aviary.yaml similarity index 100% rename from aviary.yaml rename to codemod/aviary.yaml diff --git a/example/deprecated_remover.dart b/codemod/example/deprecated_remover.dart similarity index 100% rename from example/deprecated_remover.dart rename to codemod/example/deprecated_remover.dart diff --git a/example/deprecated_remover_fixtures/deprecations.dart b/codemod/example/deprecated_remover_fixtures/deprecations.dart similarity index 100% rename from example/deprecated_remover_fixtures/deprecations.dart rename to codemod/example/deprecated_remover_fixtures/deprecations.dart diff --git a/example/is_even_or_odd_suggestor.dart b/codemod/example/is_even_or_odd_suggestor.dart similarity index 100% rename from example/is_even_or_odd_suggestor.dart rename to codemod/example/is_even_or_odd_suggestor.dart diff --git a/example/is_even_or_odd_suggestor_fixtures/is_even_or_odd.dart b/codemod/example/is_even_or_odd_suggestor_fixtures/is_even_or_odd.dart similarity index 100% rename from example/is_even_or_odd_suggestor_fixtures/is_even_or_odd.dart rename to codemod/example/is_even_or_odd_suggestor_fixtures/is_even_or_odd.dart diff --git a/example/license_header_fixtures/has_license.dart b/codemod/example/license_header_fixtures/has_license.dart similarity index 100% rename from example/license_header_fixtures/has_license.dart rename to codemod/example/license_header_fixtures/has_license.dart diff --git a/example/license_header_fixtures/missing_license.dart b/codemod/example/license_header_fixtures/missing_license.dart similarity index 100% rename from example/license_header_fixtures/missing_license.dart rename to codemod/example/license_header_fixtures/missing_license.dart diff --git a/example/license_header_inserter.dart b/codemod/example/license_header_inserter.dart similarity index 100% rename from example/license_header_inserter.dart rename to codemod/example/license_header_inserter.dart diff --git a/example/readme.md b/codemod/example/readme.md similarity index 100% rename from example/readme.md rename to codemod/example/readme.md diff --git a/example/regex_substituter.dart b/codemod/example/regex_substituter.dart similarity index 100% rename from example/regex_substituter.dart rename to codemod/example/regex_substituter.dart diff --git a/codemod/example/regex_substituter_fixtures/pubspec.yaml b/codemod/example/regex_substituter_fixtures/pubspec.yaml new file mode 100644 index 0000000..7930654 --- /dev/null +++ b/codemod/example/regex_substituter_fixtures/pubspec.yaml @@ -0,0 +1,14 @@ +name: regex_substituter_example +private: true +version: 0.0.0 +description: regex example +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + codemod: + path: ../../ + codemod_core: + path: ../../../codemod_core diff --git a/images/demo.gif b/codemod/images/demo.gif similarity index 100% rename from images/demo.gif rename to codemod/images/demo.gif diff --git a/lib/codemod.dart b/codemod/lib/codemod.dart similarity index 70% rename from lib/codemod.dart rename to codemod/lib/codemod.dart index 2edea7d..cd44cfa 100644 --- a/lib/codemod.dart +++ b/codemod/lib/codemod.dart @@ -12,17 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -export 'src/aggregate.dart' show aggregate; -export 'src/ast_visiting_suggestor.dart' show AstVisitingSuggestor; -export 'src/file_context.dart' show FileContext; -export 'src/file_query_util.dart' +export 'package:codemod_core/codemod_core.dart' show + aggregate, + AstVisitingSuggestor, + FileContext, + Patch, + Suggestor, filePathsFromGlob, - isHiddenFile, - isNotHiddenFile, isDartHiddenFile, - isNotDartHiddenFile; -export 'src/patch.dart' show Patch; + isHiddenFile, + isNotDartHiddenFile, + isNotHiddenFile; + export 'src/run_interactive_codemod.dart' show runInteractiveCodemod, runInteractiveCodemodSequence; -export 'src/suggestor.dart' show Suggestor; diff --git a/lib/src/constants.dart b/codemod/lib/src/constants.dart similarity index 100% rename from lib/src/constants.dart rename to codemod/lib/src/constants.dart diff --git a/lib/src/run_interactive_codemod.dart b/codemod/lib/src/run_interactive_codemod.dart similarity index 58% rename from lib/src/run_interactive_codemod.dart rename to codemod/lib/src/run_interactive_codemod.dart index 09922d1..0df4ce5 100644 --- a/lib/src/run_interactive_codemod.dart +++ b/codemod/lib/src/run_interactive_codemod.dart @@ -14,16 +14,12 @@ import 'dart:io'; -import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; import 'package:args/args.dart'; -import 'package:codemod/codemod.dart'; +import 'package:codemod_core/codemod_core.dart'; import 'package:io/ansi.dart'; import 'package:io/io.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart' as p; -import 'logging.dart'; -import 'patch.dart'; import 'util.dart'; /// Interactively runs a "codemod" by using `stdout` to display a diff for each @@ -76,16 +72,20 @@ Future runInteractiveCodemod( Suggestor suggestor, { Iterable args = const [], bool defaultYes = false, + bool interactive = true, String? additionalHelpOutput, String? changesRequiredOutput, + List? destPaths, }) => runInteractiveCodemodSequence( filePaths, [suggestor], args: args, defaultYes: defaultYes, + interactive: interactive, additionalHelpOutput: additionalHelpOutput, changesRequiredOutput: changesRequiredOutput, + destPaths: destPaths, ); /// Exactly the same as [runInteractiveCodemod] except that it runs all of the @@ -110,8 +110,10 @@ Future runInteractiveCodemodSequence( Iterable suggestors, { Iterable args = const [], bool defaultYes = false, + bool interactive = true, String? additionalHelpOutput, String? changesRequiredOutput, + List? destPaths, }) async { try { ArgResults parsedArgs; @@ -140,9 +142,15 @@ Future runInteractiveCodemodSequence( } return overrideAnsiOutput>( stdout.supportsAnsiEscapes, - () => _runInteractiveCodemod(filePaths, suggestors, parsedArgs, - defaultYes: defaultYes, - changesRequiredOutput: changesRequiredOutput)); + () => _runInteractiveCodemod( + filePaths, + suggestors, + parsedArgs, + defaultYes: defaultYes, + interactive: interactive, + changesRequiredOutput: changesRequiredOutput, + destPaths: destPaths, + )); } catch (error, stackTrace) { stderr ..writeln('Uncaught exception:') @@ -183,23 +191,30 @@ final codemodArgParser = ArgParser() help: 'Forces ansi color highlighting of stderr. Useful for debugging.', ); -Future _runInteractiveCodemod(Iterable filePaths, - Iterable suggestors, ArgResults parsedArgs, - {bool? defaultYes, String? changesRequiredOutput}) async { +Future _runInteractiveCodemod( + Iterable filePaths, + Iterable suggestors, + ArgResults parsedArgs, { + bool interactive = true, + bool defaultYes = false, + String? changesRequiredOutput, + List? destPaths, +}) async { + if (destPaths != null) { + assert( + filePaths.length == destPaths.length, + 'number of destPaths must be equal to the number of filePaths', + ); + } + final failOnChanges = (parsedArgs['fail-on-changes'] as bool?) ?? false; final stderrAssumeTty = (parsedArgs['stderr-assume-tty'] as bool?) ?? false; final verbose = (parsedArgs['verbose'] as bool?) ?? false; var yesToAll = (parsedArgs['yes-to-all'] as bool?) ?? false; - defaultYes ??= false; var numChanges = 0; // Pipe logs to stderr. - Logger.root.level = verbose ? Level.ALL : Level.INFO; - Logger.root.onRecord.listen(logListener( - stderr, - ansiOutputEnabled: stderr.supportsAnsiEscapes || stderrAssumeTty == true, - verbose: verbose, - )); + _configureLogger(verbose, stderrAssumeTty); // Warn and exit early if there are no inputs. if (filePaths.isEmpty) { @@ -209,98 +224,62 @@ Future _runInteractiveCodemod(Iterable filePaths, // Setup analysis for any suggestors that may need it. logger.info('Setting up analysis contexts...'); - final canonicalizedPaths = - filePaths.map((path) => p.canonicalize(path)).toList(); - final collection = - AnalysisContextCollection(includedPaths: canonicalizedPaths); - final fileContexts = - canonicalizedPaths.map((path) => FileContext(path, collection)); + + final patchGenerator = PatchGenerator(suggestors); logger.info('done'); final skippedPatches = []; stdout.writeln('searching...'); - for (final suggestor in suggestors) { - for (final context in fileContexts) { - logger.fine('file: ${context.relativePath}'); - final appliedPatches = []; - try { - final patches = await suggestor(context) - .map((p) => SourcePatch.from(p, context.sourceFile)) - .toList(); - for (final patch in patches) { - if (patch.isNoop) { - // Patch suggested, but without any changes. This is probably an - // error in the suggestor implementation. - logger.severe('Empty patch suggested: $patch'); - return ExitCode.software.code; - } + var patchStream = + patchGenerator.generate(paths: filePaths, destPaths: destPaths); - if (failOnChanges) { - // In this mode, we only count the number of changes that would have - // been suggested instead of actually suggesting them. - numChanges++; - continue; - } + await for (final changeSet in patchStream) { + final appliedPatches = []; + try { + for (var patch in changeSet.patches) { + if (failOnChanges) { + // In this mode, we only count the number of changes that would have + // been suggested instead of actually suggesting them. + numChanges++; + continue; + } - stdout.write(terminalClear()); - stdout.write(patch.renderRange()); - stdout.writeln(); - - final diffSize = calculateDiffSize(stdout); - logger.fine('diff size: $diffSize'); - stdout.write(patch.renderDiff(diffSize)); - stdout.writeln(); - - final defaultChoice = defaultYes ? 'y' : 'n'; - String choice; - if (!yesToAll) { - if (defaultYes) { - stdout.writeln('Accept change (y = yes [default], n = no, ' - 'A = yes to all, q = quit)? '); - } else { - stdout.writeln('Accept change (y = yes, n = no [default], ' - 'A = yes to all, q = quit)? '); - } - - choice = prompt('ynAq', defaultChoice); - } else { - logger.fine('skipped prompt because yesToAll==true'); - choice = 'y'; - } + var choice = _acceptPatch(patch, defaultYes, yesToAll, interactive); - if (choice == 'A') { - yesToAll = true; - choice = 'y'; - } - if (choice == 'y') { - logger.fine('patch accepted: $patch'); - appliedPatches.add(patch); - } - if (choice == 'q') { - logger.fine('applying patches'); - var userSkipped = promptToHandleOverlappingPatches(appliedPatches); - // Store patch(es) to print info about skipped patches after codemodding. - skippedPatches.addAll(userSkipped); - - // Don't apply the patches the user skipped. - for (var patch in userSkipped) { - appliedPatches.remove(patch); - logger.fine('skipping patch ${patch}'); - } - - applyPatchesAndSave(context.sourceFile, appliedPatches); - logger.fine('quitting'); - return ExitCode.success.code; + if (choice == Choice.yesToAll) { + yesToAll = true; + choice = Choice.yes; + } + if (choice == Choice.yes) { + logger.fine('patch accepted: $patch'); + appliedPatches.add(patch); + } + if (choice == Choice.quit) { + logger.fine('applying patches'); + var userSkipped = promptToHandleOverlappingPatches(appliedPatches); + // Store patch(es) to print info about skipped patches after codemodding. + skippedPatches.addAll(userSkipped); + + // Don't apply the patches the user skipped. + for (var patch in userSkipped) { + appliedPatches.remove(patch); + logger.fine('skipping patch ${patch}'); } + + ChangeSet(changeSet.sourceFile, appliedPatches).applyAndSave(); + logger.fine('quitting'); + return ExitCode.success.code; } - } catch (e, stackTrace) { - logger.severe( - 'Suggestor.generatePatches() threw unexpectedly.', e, stackTrace); - return ExitCode.software.code; } + } catch (e, stackTrace) { + logger.severe( + 'Suggestor.generatePatches() threw unexpectedly.', e, stackTrace); + return ExitCode.software.code; + } - if (!failOnChanges) { + if (!failOnChanges) { + if (interactive) { logger.fine('applying patches'); var userSkipped = promptToHandleOverlappingPatches(appliedPatches); @@ -312,35 +291,127 @@ Future _runInteractiveCodemod(Iterable filePaths, appliedPatches.remove(patch); logger.fine('skipping patch ${patch}'); } - - applyPatchesAndSave(context.sourceFile, appliedPatches); } + + ChangeSet( + changeSet.sourceFile, + appliedPatches, + destinationPath: changeSet.destinationPath, + ).applyAndSave(); } } logger.fine('done'); - for (var patch in skippedPatches) { - stdout.writeln( - 'NOTE: Overlapping patch was skipped. May require manual modification.'); - stdout.writeln(' ${patch.toString()}'); - stdout.writeln(' Updated text:'); - stdout.writeln(' ${patch.updatedText}'); - stdout.writeln(''); - } + return _showChanges( + interactive: interactive, + failOnChanges: failOnChanges, + numChanges: numChanges, + skippedPatches: skippedPatches, + changesRequiredOutput: changesRequiredOutput); +} - if (failOnChanges) { - if (numChanges > 0) { - stderr.writeln('$numChanges change(s) needed.'); +void _configureLogger(bool verbose, bool stderrAssumeTty) { + Logger.root.level = verbose ? Level.ALL : Level.INFO; + Logger.root.onRecord.listen(logListener( + stderr, + ansiOutputEnabled: stderr.supportsAnsiEscapes || stderrAssumeTty == true, + verbose: verbose, + )); +} - changesRequiredOutput ??= ''; - if (changesRequiredOutput.isNotEmpty) { - stderr.writeln(); - stderr.writeln(changesRequiredOutput); +int _showChanges( + {required bool interactive, + required bool failOnChanges, + required int numChanges, + required List skippedPatches, + required String? changesRequiredOutput}) { + if (interactive) { + for (var patch in skippedPatches) { + stdout.writeln( + 'NOTE: Overlapping patch was skipped. May require manual modification.'); + stdout.writeln(' ${patch.toString()}'); + stdout.writeln(' Updated text:'); + stdout.writeln(' ${patch.updatedText}'); + stdout.writeln(''); + } + + if (failOnChanges) { + if (numChanges > 0) { + stderr.writeln('$numChanges change(s) needed.'); + + changesRequiredOutput ??= ''; + if (changesRequiredOutput.isNotEmpty) { + stderr.writeln(); + stderr.writeln(changesRequiredOutput); + } + return 1; } - return 1; + stdout.writeln('No changes needed.'); } - stdout.writeln('No changes needed.'); } - return ExitCode.success.code; } + +enum Choice { + yes, + no, + yesToAll, + quit; + + static Choice fromString(String response) { + switch (response) { + case 'y': + return Choice.yes; + case 'A': + return Choice.yesToAll; + case 'n': + return Choice.no; + + case 'q': + return Choice.quit; + default: + throw InputException( + 'Unexpected response provided: expected one of yAnq'); + } + } +} + +Choice _acceptPatch( + SourcePatch patch, bool defaultYes, bool yesToAll, bool interactive) { + if (!interactive) { + return Choice.yes; + } + + final defaultChoice = defaultYes ? 'y' : 'n'; + + _showPatch(patch); + + var choice = Choice.no; + if (!yesToAll) { + if (defaultYes) { + stdout.writeln('Accept change (y = yes [default], n = no, ' + 'A = yes to all, q = quit)? '); + } else { + stdout.writeln('Accept change (y = yes, n = no [default], ' + 'A = yes to all, q = quit)? '); + } + + final response = prompt('ynAq', defaultChoice); + choice = Choice.fromString(response); + } else { + logger.fine('skipped prompt because yesToAll==true'); + choice = Choice.yes; + } + return choice; +} + +void _showPatch(SourcePatch patch) { + stdout.write(terminalClear()); + stdout.write(patch.renderRange()); + stdout.writeln(); + + final diffSize = calculateDiffSize(stdout); + logger.fine('diff size: $diffSize'); + stdout.write(patch.renderDiff(diffSize)); + stdout.writeln(); +} diff --git a/lib/src/util.dart b/codemod/lib/src/util.dart similarity index 67% rename from lib/src/util.dart rename to codemod/lib/src/util.dart index a7fc0a5..5ff28e4 100644 --- a/lib/src/util.dart +++ b/codemod/lib/src/util.dart @@ -12,67 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'dart:math' as math; import 'dart:io'; +import 'dart:math' as math; +import 'package:codemod_core/codemod_core.dart'; import 'package:io/ansi.dart'; -import 'package:source_span/source_span.dart'; import 'constants.dart'; -import 'patch.dart'; - -/// Returns the result of applying all of the [patches] -/// (insertions/deletions/replacements) to the contents of [sourceFile]. -/// -/// Throws an [Exception] if any two of the given [patches] overlap. -String applyPatches(SourceFile sourceFile, Iterable patches) { - final buffer = StringBuffer(); - final sortedPatches = - patches.map((p) => SourcePatch.from(p, sourceFile)).toList()..sort(); - - var lastEdgeOffset = 0; - late Patch prev; - for (final patch in sortedPatches) { - if (patch.startOffset < lastEdgeOffset) { - throw Exception('Codemod terminated due to overlapping patch.\n' - 'Previous patch:\n' - ' $prev\n' - ' Updated text: ${prev.updatedText}\n' - 'Overlapping patch:\n' - ' $patch\n' - ' Updated text: ${patch.updatedText}\n'); - } - - // Write unmodified text from end of last patch to beginning of this patch - buffer.write(sourceFile.getText(lastEdgeOffset, patch.startOffset)); - // Write the patched text (and do nothing with the original text, which is - // effectively the same as replacing it) - buffer.write(patch.updatedText); - - lastEdgeOffset = patch.endOffset; - prev = patch; - } - - buffer.write(sourceFile.getText(lastEdgeOffset)); - return buffer.toString(); -} - -/// Applies all of the [patches] (insertions/deletions/replacements) to the -/// contents of [sourceFile] and writes the result to disk. -/// -/// Throws an [ArgumentError] if [sourceFile] has a null value for -/// [SourceFile.url], as it is required to open the file and write the new -/// contents. -void applyPatchesAndSave(SourceFile sourceFile, Iterable patches) { - if (patches.isEmpty) { - return; - } - if (sourceFile.url == null) { - throw ArgumentError('sourceFile.url cannot be null'); - } - final updatedContents = applyPatches(sourceFile, patches); - File.fromUri(sourceFile.url!).writeAsStringSync(updatedContents); -} /// Finds overlapping patches and prompts the user to decide how to handle them. /// diff --git a/lib/test.dart b/codemod/lib/test.dart similarity index 89% rename from lib/test.dart rename to codemod/lib/test.dart index 2b6d5cd..e84af53 100644 --- a/lib/test.dart +++ b/codemod/lib/test.dart @@ -1,14 +1,9 @@ import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:codemod_core/codemod_core.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; -import 'src/file_context.dart'; -import 'src/suggestor.dart'; -import 'src/util.dart'; - -export 'src/util.dart' show applyPatches; - /// Creates a file with the given [name] and [sourceText] using the /// `test_descriptor` package, sets up analysis for that file, and returns a /// [FileContext] wrapper around it. @@ -51,8 +46,7 @@ Future fileContextForTest(String name, String sourceText) async { void expectSuggestorGeneratesPatches( Suggestor suggestor, FileContext context, dynamic resultMatcher) { expect( - suggestor(context) - .toList() - .then((patches) => applyPatches(context.sourceFile, patches)), + suggestor(context).toList().then((patches) => + ChangeSet.fromPatchs(context.sourceFile, patches).apply()), completion(resultMatcher)); } diff --git a/pubspec.yaml b/codemod/pubspec.yaml similarity index 64% rename from pubspec.yaml rename to codemod/pubspec.yaml index 40d2789..64676cf 100644 --- a/pubspec.yaml +++ b/codemod/pubspec.yaml @@ -2,16 +2,14 @@ name: codemod version: 1.0.11 homepage: https://github.com/Workiva/dart_codemod -description: > - Write and run automated code modifications on a codebase. Primarily geared - towards updating and refactoring Dart code, but can modify any files. +description: Write and run automated code modifications on a codebase. Primarily geared towards updating and refactoring Dart code, but can modify any files. environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: - analyzer: ^5.0.0 args: ^2.0.0 + codemod_core: ^1.0.0 glob: ^2.0.1 io: ^1.0.0 logging: ^1.0.1 @@ -29,3 +27,4 @@ dev_dependencies: meta: ^1.6.0 mockito: ^5.3.2 pedantic: ^1.11.0 + pubspec_manager: ^1.0.0-alpha.11 diff --git a/codemod/pubspec_overrides.yaml b/codemod/pubspec_overrides.yaml new file mode 100644 index 0000000..9952a54 --- /dev/null +++ b/codemod/pubspec_overrides.yaml @@ -0,0 +1,3 @@ +dependency_overrides: + codemod_core: + path: ../codemod_core \ No newline at end of file diff --git a/skynet.yaml b/codemod/skynet.yaml similarity index 100% rename from skynet.yaml rename to codemod/skynet.yaml diff --git a/test/aggregate_suggestor_test.dart b/codemod/test/aggregate_suggestor_test.dart similarity index 97% rename from test/aggregate_suggestor_test.dart rename to codemod/test/aggregate_suggestor_test.dart index ccae4e7..fa38b47 100644 --- a/test/aggregate_suggestor_test.dart +++ b/codemod/test/aggregate_suggestor_test.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +// ignore_for_file: must_be_immutable + @TestOn('vm') import 'package:codemod/codemod.dart'; import 'package:codemod/test.dart'; diff --git a/test/aggregate_suggestor_test.mocks.dart b/codemod/test/aggregate_suggestor_test.mocks.dart similarity index 92% rename from test/aggregate_suggestor_test.mocks.dart rename to codemod/test/aggregate_suggestor_test.mocks.dart index 7c70643..9c93a42 100644 --- a/test/aggregate_suggestor_test.mocks.dart +++ b/codemod/test/aggregate_suggestor_test.mocks.dart @@ -2,7 +2,9 @@ // in codemod/test/aggregate_suggestor_test.dart. // Do not manually edit this file. -import 'package:codemod/src/patch.dart' as _i2; +// ignore_for_file: must_be_immutable + +import 'package:codemod_core/src/patch.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: comment_references diff --git a/test/ast_visiting_suggestor_test.dart b/codemod/test/ast_visiting_suggestor_test.dart similarity index 100% rename from test/ast_visiting_suggestor_test.dart rename to codemod/test/ast_visiting_suggestor_test.dart diff --git a/test/examples/deprecated_remover_test.dart b/codemod/test/examples/deprecated_remover_test.dart similarity index 100% rename from test/examples/deprecated_remover_test.dart rename to codemod/test/examples/deprecated_remover_test.dart diff --git a/test/examples/is_even_or_odd_suggestor_test.dart b/codemod/test/examples/is_even_or_odd_suggestor_test.dart similarity index 100% rename from test/examples/is_even_or_odd_suggestor_test.dart rename to codemod/test/examples/is_even_or_odd_suggestor_test.dart diff --git a/test/examples/license_header_inserter_test.dart b/codemod/test/examples/license_header_inserter_test.dart similarity index 100% rename from test/examples/license_header_inserter_test.dart rename to codemod/test/examples/license_header_inserter_test.dart diff --git a/test/examples/regex_substituter_test.dart b/codemod/test/examples/regex_substituter_test.dart similarity index 100% rename from test/examples/regex_substituter_test.dart rename to codemod/test/examples/regex_substituter_test.dart diff --git a/test/file_query_util_test.dart b/codemod/test/file_query_util_test.dart similarity index 98% rename from test/file_query_util_test.dart rename to codemod/test/file_query_util_test.dart index 1450382..b8c79a7 100644 --- a/test/file_query_util_test.dart +++ b/codemod/test/file_query_util_test.dart @@ -14,10 +14,9 @@ @TestOn('vm') import 'dart:io'; +import 'package:codemod/codemod.dart'; import 'package:test/test.dart'; -import 'package:codemod/src/file_query_util.dart'; - void main() { group('isHiddenFile', () { test('returns false for non-hidden files', () { diff --git a/test/functional/run_interactive_codemod_test.dart b/codemod/test/functional/run_interactive_codemod_test.dart similarity index 94% rename from test/functional/run_interactive_codemod_test.dart rename to codemod/test/functional/run_interactive_codemod_test.dart index 95d984e..e0753c7 100644 --- a/test/functional/run_interactive_codemod_test.dart +++ b/codemod/test/functional/run_interactive_codemod_test.dart @@ -16,13 +16,12 @@ import 'dart:convert'; import 'dart:io'; +import 'package:codemod/src/run_interactive_codemod.dart' show codemodArgParser; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; -import 'package:codemod/src/run_interactive_codemod.dart' show codemodArgParser; - // Change this to `true` and all of the functional tests in this file will print // the stdout/stderr of the codemod processes. final _debug = false; @@ -53,9 +52,25 @@ Future testCodemod( // The test project has a path dependency on this codemod package, but we // need to update it to be absolute so that it works from the temp dir. final pubspec = File(d.path('project/pubspec.yaml')); - pubspec.writeAsStringSync(pubspec - .readAsStringSync() - .replaceAll('path: ../../../', 'path: ${p.current}')); + pubspec.writeAsStringSync(pubspec.readAsStringSync().replaceAll( + ''' + codemod: + path: ../../../''', + ''' + codemod: + path: ${p.current}''', + )); + + final pubspecOverrides = File(d.path('project/pubspec_overrides.yaml')); + pubspecOverrides + .writeAsStringSync(pubspecOverrides.readAsStringSync().replaceAll( + ''' + codemod_core: + path: ../../../../codemod_core''', + ''' + codemod_core: + path: ${p.current}/../codemod_core''', + )); final pubGetResult = await Process.run( 'dart', diff --git a/test/logging_test.dart b/codemod/test/logging_test.dart similarity index 98% rename from test/logging_test.dart rename to codemod/test/logging_test.dart index 57861e9..305f0f7 100644 --- a/test/logging_test.dart +++ b/codemod/test/logging_test.dart @@ -12,14 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:codemod_core/src/logging.dart'; @TestOn('vm') import 'package:io/ansi.dart'; import 'package:logging/logging.dart'; import 'package:stack_trace/stack_trace.dart'; import 'package:test/test.dart'; -import 'package:codemod/src/logging.dart'; - import 'util.dart'; const codemodLoggerName = 'codemod'; diff --git a/test/patch_test.dart b/codemod/test/patch_test.dart similarity index 99% rename from test/patch_test.dart rename to codemod/test/patch_test.dart index 2ef5406..aca0a67 100644 --- a/test/patch_test.dart +++ b/codemod/test/patch_test.dart @@ -12,13 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:codemod_core/src/patch.dart'; @TestOn('vm') import 'package:io/ansi.dart'; import 'package:source_span/source_span.dart'; import 'package:test/test.dart'; -import 'package:codemod/src/patch.dart'; - import 'util.dart'; final contents = ''' diff --git a/test/util.dart b/codemod/test/util.dart similarity index 100% rename from test/util.dart rename to codemod/test/util.dart diff --git a/codemod/test/util_test.dart b/codemod/test/util_test.dart new file mode 100644 index 0000000..63a9744 --- /dev/null +++ b/codemod/test/util_test.dart @@ -0,0 +1,50 @@ +// Copyright 2019 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. + +@TestOn('vm') +import 'dart:io'; + +import 'package:codemod/src/util.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'util_test.mocks.dart'; + +@GenerateMocks([Stdout]) +void main() { + group('Utils', () { + group('calculateDiffSize()', () { + test('returns 10 if stdout does not have a terminal', () { + final mockStdout = MockStdout(); + when(mockStdout.hasTerminal).thenReturn(false); + expect(calculateDiffSize(mockStdout), 10); + }); + + test('returns 10 if # of terminal lines is too small', () { + final mockStdout = MockStdout(); + when(mockStdout.hasTerminal).thenReturn(true); + when(mockStdout.terminalLines).thenReturn(15); + expect(calculateDiffSize(mockStdout), 10); + }); + + test('returns 10 less than available # of terminal lines', () { + final mockStdout = MockStdout(); + when(mockStdout.hasTerminal).thenReturn(true); + when(mockStdout.terminalLines).thenReturn(50); + expect(calculateDiffSize(mockStdout), 40); + }); + }); + }); +} diff --git a/test/util_test.mocks.dart b/codemod/test/util_test.mocks.dart similarity index 100% rename from test/util_test.mocks.dart rename to codemod/test/util_test.mocks.dart diff --git a/test_fixtures/functional/after_all_patches/file1.txt b/codemod/test_fixtures/functional/after_all_patches/file1.txt similarity index 100% rename from test_fixtures/functional/after_all_patches/file1.txt rename to codemod/test_fixtures/functional/after_all_patches/file1.txt diff --git a/test_fixtures/functional/after_all_patches/file2.txt b/codemod/test_fixtures/functional/after_all_patches/file2.txt similarity index 100% rename from test_fixtures/functional/after_all_patches/file2.txt rename to codemod/test_fixtures/functional/after_all_patches/file2.txt diff --git a/test_fixtures/functional/after_all_patches/skip.txt b/codemod/test_fixtures/functional/after_all_patches/skip.txt similarity index 100% rename from test_fixtures/functional/after_all_patches/skip.txt rename to codemod/test_fixtures/functional/after_all_patches/skip.txt diff --git a/test_fixtures/functional/after_no_patches/file1.txt b/codemod/test_fixtures/functional/after_no_patches/file1.txt similarity index 100% rename from test_fixtures/functional/after_no_patches/file1.txt rename to codemod/test_fixtures/functional/after_no_patches/file1.txt diff --git a/test_fixtures/functional/after_no_patches/file2.txt b/codemod/test_fixtures/functional/after_no_patches/file2.txt similarity index 100% rename from test_fixtures/functional/after_no_patches/file2.txt rename to codemod/test_fixtures/functional/after_no_patches/file2.txt diff --git a/test_fixtures/functional/after_no_patches/skip.txt b/codemod/test_fixtures/functional/after_no_patches/skip.txt similarity index 100% rename from test_fixtures/functional/after_no_patches/skip.txt rename to codemod/test_fixtures/functional/after_no_patches/skip.txt diff --git a/test_fixtures/functional/after_overlapping_patches_skip/file1.txt b/codemod/test_fixtures/functional/after_overlapping_patches_skip/file1.txt similarity index 100% rename from test_fixtures/functional/after_overlapping_patches_skip/file1.txt rename to codemod/test_fixtures/functional/after_overlapping_patches_skip/file1.txt diff --git a/test_fixtures/functional/after_overlapping_patches_skip/file2.txt b/codemod/test_fixtures/functional/after_overlapping_patches_skip/file2.txt similarity index 100% rename from test_fixtures/functional/after_overlapping_patches_skip/file2.txt rename to codemod/test_fixtures/functional/after_overlapping_patches_skip/file2.txt diff --git a/test_fixtures/functional/after_overlapping_patches_skip/skip.txt b/codemod/test_fixtures/functional/after_overlapping_patches_skip/skip.txt similarity index 100% rename from test_fixtures/functional/after_overlapping_patches_skip/skip.txt rename to codemod/test_fixtures/functional/after_overlapping_patches_skip/skip.txt diff --git a/test_fixtures/functional/after_some_patches/file1.txt b/codemod/test_fixtures/functional/after_some_patches/file1.txt similarity index 100% rename from test_fixtures/functional/after_some_patches/file1.txt rename to codemod/test_fixtures/functional/after_some_patches/file1.txt diff --git a/test_fixtures/functional/after_some_patches/file2.txt b/codemod/test_fixtures/functional/after_some_patches/file2.txt similarity index 100% rename from test_fixtures/functional/after_some_patches/file2.txt rename to codemod/test_fixtures/functional/after_some_patches/file2.txt diff --git a/test_fixtures/functional/after_some_patches/skip.txt b/codemod/test_fixtures/functional/after_some_patches/skip.txt similarity index 100% rename from test_fixtures/functional/after_some_patches/skip.txt rename to codemod/test_fixtures/functional/after_some_patches/skip.txt diff --git a/test_fixtures/functional/before/codemod.dart b/codemod/test_fixtures/functional/before/codemod.dart similarity index 100% rename from test_fixtures/functional/before/codemod.dart rename to codemod/test_fixtures/functional/before/codemod.dart diff --git a/test_fixtures/functional/before/codemod_changes_required_output.dart b/codemod/test_fixtures/functional/before/codemod_changes_required_output.dart similarity index 100% rename from test_fixtures/functional/before/codemod_changes_required_output.dart rename to codemod/test_fixtures/functional/before/codemod_changes_required_output.dart diff --git a/test_fixtures/functional/before/codemod_default_yes.dart b/codemod/test_fixtures/functional/before/codemod_default_yes.dart similarity index 100% rename from test_fixtures/functional/before/codemod_default_yes.dart rename to codemod/test_fixtures/functional/before/codemod_default_yes.dart diff --git a/test_fixtures/functional/before/codemod_help_output.dart b/codemod/test_fixtures/functional/before/codemod_help_output.dart similarity index 100% rename from test_fixtures/functional/before/codemod_help_output.dart rename to codemod/test_fixtures/functional/before/codemod_help_output.dart diff --git a/test_fixtures/functional/before/codemod_no_patches.dart b/codemod/test_fixtures/functional/before/codemod_no_patches.dart similarity index 100% rename from test_fixtures/functional/before/codemod_no_patches.dart rename to codemod/test_fixtures/functional/before/codemod_no_patches.dart diff --git a/test_fixtures/functional/before/codemod_overlapping_patches.dart b/codemod/test_fixtures/functional/before/codemod_overlapping_patches.dart similarity index 100% rename from test_fixtures/functional/before/codemod_overlapping_patches.dart rename to codemod/test_fixtures/functional/before/codemod_overlapping_patches.dart diff --git a/test_fixtures/functional/before/file1.txt b/codemod/test_fixtures/functional/before/file1.txt similarity index 100% rename from test_fixtures/functional/before/file1.txt rename to codemod/test_fixtures/functional/before/file1.txt diff --git a/test_fixtures/functional/before/file2.txt b/codemod/test_fixtures/functional/before/file2.txt similarity index 100% rename from test_fixtures/functional/before/file2.txt rename to codemod/test_fixtures/functional/before/file2.txt diff --git a/test_fixtures/functional/before/pubspec.yaml b/codemod/test_fixtures/functional/before/pubspec.yaml similarity index 71% rename from test_fixtures/functional/before/pubspec.yaml rename to codemod/test_fixtures/functional/before/pubspec.yaml index d21f5e6..e4a5dc0 100644 --- a/test_fixtures/functional/before/pubspec.yaml +++ b/codemod/test_fixtures/functional/before/pubspec.yaml @@ -1,8 +1,9 @@ name: codemod_test_fixture version: 0.0.0 private: true +description: test fixture environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dev_dependencies: codemod: path: ../../../ diff --git a/codemod/test_fixtures/functional/before/pubspec_overrides.yaml b/codemod/test_fixtures/functional/before/pubspec_overrides.yaml new file mode 100644 index 0000000..a90b97b --- /dev/null +++ b/codemod/test_fixtures/functional/before/pubspec_overrides.yaml @@ -0,0 +1,3 @@ +dependency_overrides: + codemod_core: + path: ../../../../codemod_core \ No newline at end of file diff --git a/test_fixtures/functional/before/skip.txt b/codemod/test_fixtures/functional/before/skip.txt similarity index 100% rename from test_fixtures/functional/before/skip.txt rename to codemod/test_fixtures/functional/before/skip.txt diff --git a/codemod_core.code-workspace b/codemod_core.code-workspace new file mode 100644 index 0000000..24861ad --- /dev/null +++ b/codemod_core.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "codemod_core" + }, + { + "path": "codemod" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/codemod_core/.gitignore b/codemod_core/.gitignore new file mode 100644 index 0000000..7117aad --- /dev/null +++ b/codemod_core/.gitignore @@ -0,0 +1,4 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ +.failed_tracker diff --git a/codemod_core/CHANGELOG.md b/codemod_core/CHANGELOG.md new file mode 100644 index 0000000..b615f07 --- /dev/null +++ b/codemod_core/CHANGELOG.md @@ -0,0 +1,16 @@ +# 1.0.1 +- Added additional expectations for the named generated test and removed the print statement. +- Modified change_set so that it always creates the output file even if no patches were included. This creates a more consistent expectation - I apply a changes set and an output file is created. +- updated the barrel file to reflect that file utils have been moved to core. +- moved file_query_util into core as we needed access for examples. +- applied lint_hard and cleaned up the resulting lints. +- Added a 'collision' list to the ChangeSet so you can get a list of overlapping patches that were skipped. +- Moved the applyPatches and applyPatchesAndSave to the ChangeSet class. +- Added option to ignore overlapping patches. +- modified run_interactive to use the new patch_generator +- split the project into two packages, codemon and codemon_core. The codemon package has exactly the same functionality as before except that the underlying ast traversal and creation of patches has been moved to codemon_core. The idea is that codemon_core can be used as an standalone API outside of the codemon CLI tooling. +- support destPaths + +## 1.0.0 + +- Initial version. diff --git a/codemod_core/LICENSE b/codemod_core/LICENSE new file mode 100644 index 0000000..e3bb247 --- /dev/null +++ b/codemod_core/LICENSE @@ -0,0 +1,30 @@ +Copyright 2019 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. + +-------------------------------------------------- + +This dart_codemod software includes a number of subcomponents with +separate copyright notices and/or license terms. Your use of the source +code for the these subcomponents is subject to the terms and +conditions of the following licenses: + +Facebook Codemod software: https://github.com/facebook/codemod +Copyright ©2004 Facebook. +Licensed under the Apache License v2.0: https://github.com/facebook/codemod/blob/master/LICENSE + +Dart-lang build software: https://github.com/dart-lang/build +Copyright ©2016 Dart project authors. +Licensed under the BSD 3-Clause License: https://github.com/dart-lang/build/blob/master/LICENSE + +-------------------------------------------------- \ No newline at end of file diff --git a/codemod_core/README.md b/codemod_core/README.md new file mode 100644 index 0000000..f9c98d1 --- /dev/null +++ b/codemod_core/README.md @@ -0,0 +1,409 @@ +# codemod_core + +A library that makes it easy to write and run automated code modifications +on a codebase. Primarily geared towards updating/refactoring Dart code by +leveraging the [analyzer][analyzer] package's APIs for parsing and traversing +the AST. + +CodeMode Core provides an extensible api that allows you to generate +modifications to Dart source code. + +codemod_core comes out of the codemod project which provides an interactive console apppliation that lets you apply fixes to your code. + +codemod_core is a pure API designed to let you programmatically make +code changes by implementing 'Suggestors'. + +Note: the term 'Suggestor' comes from the original codemod project which +made interactive 'suggestions'. With codemode_core these are really not +suggetions but changes that you are going to apply to a code base. + +## How It Works + +To use codemod_core to modify your code base, you need to implement +one or more Suggestors - these will create the 'patches' that will be applied +to your code base. + +The 'patches' generated by the Suggestors are presented as a stream of 'ChangeSet's. +A ChangeSet contains a set of patches and the path to the dart library that those patches should be applied to. + + +Once you have implemented the requried set of Suggestors you need to: +* identify the set of Dart libraries (*.dart files) that need to be changed +* generate the changes sets +* apply the change sets. + +## Example: + +```dart +void main(List args) async { + /// Identify the that we want to add a license header to + var paths = filePathsFromGlob( + Glob(join('fixtures', 'license_header', '**.dart')), + ); + + /// Instantiate the [PatchGenerator] with the set of + /// Suggestors we are going to using to generate [ChangeSet]s. + var pg = PatchGenerator([licenseHeaderInserter]); + + /// Generate the [ChangeSet]s. + var changeSets = pg.generate(paths: paths); + + /// Apply the [ChangeSet] to the code. + await for (var changeSet in changeSets) { + /// Change .apply to .applyAndSave to write the changes to disk + /// overwritting the existing .dart source files. + /// --------------------------------------- + /// WARNING: backup your source first!!!! + /// --------------------------------------- + var patchedSource = changeSet.apply(); + // changeSet.applyAndSave(); + } +} +``` + + +## Writing a Suggestor + +```dart +typedef Suggestor = Stream Function(FileContext context); +``` + +Suggestor is just a typedef, so any function with that signature or class that +overrides `call()` with that signature will work. + +Codemod creates the `FileContext` instance for each file path it is given and +passes it to the suggestor; it is just a helper class with methods for reading +the file's contents and analyzing it with [`package:analyzer`][analyzer]. + +The context can be used to get the file's contents (`context.sourceText`), a +`SourceFile` representation (`context.sourceFile`) for easily referencing spans +of text within the file, or, for Dart files, the analyzed formats like the +`CompilationUnit` (unresolved or resolved) or the fully resolved +`LibraryElement`. + +### Suggestor Example: Insert License Headers + +The following suggestor checks each file for the expected license header, and if +missing, yields a `Patch` that inserts it at the beginning of the file. + +```dart +import 'package:codemod/codemod.dart'; +import 'package:source_span/source_span.dart'; + +final String licenseHeader = ''' +// Lorem ispum license. +// 2018-2019 +'''; + +Stream licenseHeaderInserter(FileContext context) async* { + // Skip if license header already exists. + if (context.sourceText.trimLeft().startsWith(licenseHeader)) return; + + yield Patch( + // Text to insert. + licenseHeader, + // Start offset. + // 0 means "insert at the beginning of the file." + 0, + // End offset. + // Using the same offset as the start offset here means that the patch + // is being inserted at this point instead of replacing a span of text. + 0, + ); +} +``` + +### Suggestor Example: Regex Substitution + +Regex substitutions are also a common strategy for codemods and are sufficient +for simple changes. The following suggestor updates a version constraint for the +`codemod` package in a `pubspec.yaml`: + +```dart +import 'package:codemod/codemod.dart'; +import 'package:source_span/source_span.dart'; + +/// Pattern that matches a dependency version constraint line for the `codemod` +/// package, with the first capture group being the constraint. +final RegExp pattern = RegExp( + r'''^\s*codemod:\s*([\d\s"'<>=^.]+)\s*$''', + multiLine: true, +); + +/// The version constraint that `codemod` entries should be updated to. +const String targetConstraint = '^1.0.0'; + +Stream regexSubstituter(FileContext context) async* { + for (final match in pattern.allMatches(context.sourceText)) { + final line = match.group(0); + final constraint = match.group(1); + final updated = line.replaceFirst(constraint, targetConstraint) + '\n'; + + yield Patch(updated, match.start, match.end); + } +} +``` + +### Suggestor Example: AST Visitor + +Regexes and custom parsing can get you pretty far, but using the +[analyzer][analyzer]'s visitor pattern to traverse the parsed AST is a much more +robust option and allows for the creation of very powerful codemods with +relatively little effort. + +Consider the following suggestor that removes all deprecated declarations +(i.e. classes, constructors, variables, methods, etc.): + +```dart +import 'package:analyzer/analyzer.dart'; +import 'package:codemod/codemod.dart'; + +class DeprecatedRemover extends GeneralizingAstVisitor + with AstVisitingSuggestor { + static bool isDeprecated(AnnotatedNode node) => + node.metadata.any((m) => m.name.name.toLowerCase() == 'deprecated'); + + @override + void visitDeclaration(Declaration node) { + if (isDeprecated(node)) { + // Remove the node by replacing the span from its start offset to its end + // offset with an empty string. + yieldPatch('', node.offset, node.end); + } + } +} +``` + +In this example, the suggestor extends the `GeneralizingAstVisitor` which allows +it to target all nodes that could be deprecated with a single visit method. Then +it's just a matter of checking for either the `@Deprecated()` or `@deprecated()` +annotation and yielding a patch with an empty string across the entire node, +which is effectively a deletion. + +You may notice that in this example, the suggestor is no longer implementing +`generatePatches()` – instead, we use `AstVisitingSuggestor`. This mixin handles +obtaining the `CompilationUnit` for the given file and starting the visitor +pattern so that all you have to do is override the applicable `visit` methods. + +Although the `GeneralizingAstVisitor` was the appropriate choice for this +suggestor, any `AstVisitor` will work. Choose whichever one fits the job. + +Note that by default `AstVisitingSuggestor` operates on a Dart file's +_unresolved_ AST, but you can override `shouldResolveAst()` to tell the mixin to +resolve the AST: + +```dart +class ExampleSuggestor extends GeneralizingAstVisitor + with AstVisitingSuggestor { + @override + bool shouldResolveAst(FileContext context) => true; + + ... +} +``` + +> If you're not familiar with the analyzer API, in particular the `AstNode` +> class hierarchy and the `AstVisitor` pattern, it may be a good opportunity to +> browse the analyzer source code or look at the AST visiting suggestor codemods +> that are linked below in the [references section](#references) to see what is +> possible with this approach. + +## Running a Codemod + +All you need to run a codemod is: + +1. A set of files to be read. + + You can create this `Iterable` input however you like. An easy + option is to use `Glob` from `package:glob` with the `filePathsFromGlob()` + util method from this package. Globs make it easy to query for files + recursively, and `filePathsFromGlob()` will filter out hidden files by + default: + + ```dart + filePathsFromGlob(Glob('**.dart', recursive: true)) + ``` + +2. A `Suggestor` to suggest patches on each file. + +3. A `.dart` file with a `main()` block that calls `runInteractiveCodemod()`. + +If we were to run the 3 suggestor examples from above, it would like like so: + +**Regex Substituter:** + +```dart +import 'dart:io'; +import 'package:codemod/codemod.dart'; + +void main(List args) async { + exitCode = await runInteractiveCodemod( + ['pubspec.yaml'], + regexSubstituter, + args: args, + ); +} +``` + +**License Header Inserter:** + +```dart +import 'dart:io'; + +import 'package:codemod/codemod.dart'; +import 'package:glob/glob.dart'; + +void main(List args) async { + exitCode = await runInteractiveCodemod( + filePathsFromGlob(Glob('**.dart', recursive: true)), + licenseHeaderInserter, + args: args, + ); +} +``` + +**Deprecated Remover:** + +```dart +import 'dart:io'; + +import 'package:codemod/codemod.dart'; +import 'package:glob/glob.dart'; + +void main(List args) async { + exitCode = await runInteractiveCodemod( + filePathsFromGlob(Glob('**.dart', recursive: true)), + DeprecatedRemover(), + args: args, + ); +} +``` + +Run the `.dart` file directly or package it up as an executable and publish it +to pub! + +## Additional Options + +To facilitate the creation of more complex codemods, two additional pieces are +provided by this library: + +- Aggregate multiple suggestors into a single suggestor with `aggregate()`: + + ```dart + import 'dart:io'; + + import 'package:codemod/codemod.dart'; + + void main(List args) async { + exitCode = await runInteractiveCodemod( + [...], // input files + aggregate([ + suggestorA, + suggestorB, + ]), + ); + } + ``` + +- Run multiple suggestors (or aggregate suggestors) sequentially: + + ```dart + import 'dart:io'; + + import 'package:codemod/codemod.dart'; + + void main(List args) async { + exitCode = await runInteractiveCodemodSequence( + [...], // input files + [ + phaseOneSuggestor, + phaseTwoSuggestor, + ], + args: args, + ); + } + ``` + + This can be useful if a certain modification needs to happen prior to + another, or if you need to use a "collector" pattern wherein the first + suggestor collects information from the files that a second suggestor will + then use to suggest patches. + +## Testing Suggestors + +Testing suggestors is relatively easy for two reasons: + +- The API surface area is small (most of the time you only need to test the + suggestor function). + +- The stream of patches returned by a suggestor can be applied to the source + file to obtain a `String` output, which can easily be compared against an + expected output. + +In other words, all you need to do is determine a sufficient set of inputs and +their respective expected outputs. + +To help out, the `package:codemod/test.dart` library exports a few functions. +These two should be sufficient for writing most suggestor tests: + +- `fileContextForTest(name, contents)` for creating a `FileContext` that can be +used as the input for `Suggestor.generatePatches()` +- `expectSuggestorGeneratesPatches(suggestor, context, resultMatcher)` for +asserting that a suggestor produces the expected result for a given input + +If, however, you need to examine the generated patches more closely, you can +call a suggestor yourself and then use the `applyPatches(sourceFile, patches)` +function to get the resulting output. + +Let's use the `DeprecatedRemover` suggestor example from above to demonstrate +testing: + +```dart +import 'package:codemod/codemod.dart'; +import 'package:source_span/source_span.dart'; +import 'package:test/test.dart'; + +void main() { + group('DeprecatedRemover', () { + test('removes deprecated variable', () async { + final context = await fileContextForTest('test.dart', ''' +// Not deprecated. +var foo = 'foo'; +@deprecated +var bar = 'bar';'''); + final expectedOutput = ''' +// Not deprecated. +var foo = 'foo'; +'''; + expectSuggestorGeneratesPatches( + DeprecatedRemover(), context, expectedOutput); + }); + }); +} +``` + +## References + +- [over_react_codemod][over_react_codemod]: codemods for the `over_react` UI + library + +## Credits + +- [facebook/codemod][facebook-codemod]: python codemod tool that this library + was based on + +--- + +## Contributing + +- **Run tests:** `dart test` + +- **Format code:** `dart format` + +- **Run static analysis:** `dart analyze` + +[analyzer]: https://pub.dartlang.org/packages/analyzer +[facebook-codemod]: https://github.com/facebook/codemod +[over_react_codemod]: https://github.com/Workiva/over_react_codemod +[SourceFile]: https://pub.dartlang.org/documentation/source_span/latest/source_span/SourceFile-class.html + diff --git a/codemod_core/analysis_options.yaml b/codemod_core/analysis_options.yaml new file mode 100644 index 0000000..28bc581 --- /dev/null +++ b/codemod_core/analysis_options.yaml @@ -0,0 +1,2 @@ +include: package:lint_hard/all.yaml + diff --git a/codemod_core/example/bin/deprecated_remover.dart b/codemod_core/example/bin/deprecated_remover.dart new file mode 100644 index 0000000..5e9d1f6 --- /dev/null +++ b/codemod_core/example/bin/deprecated_remover.dart @@ -0,0 +1,39 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example/bin +/// $ dart deprecated_remover.dart +library dart_codemod.example.deprecated_remover; + +import 'package:codemod_core/codemod_core.dart'; +import 'package:glob/glob.dart'; +import 'package:path/path.dart'; + +// ignore: avoid_relative_lib_imports +import '../lib/src/suggestors/deprecated_remover.dart'; + +void main(List args) async { + final paths = filePathsFromGlob( + Glob(join('fixtures', 'deprecated_remover', '**.dart')), + ); + final pg = PatchGenerator([DeprecatedRemover().call]); + final changeSets = pg.generate(paths: paths); + + await for (final changeSet in changeSets) { + /// Change .apply to .applyAndSave to write the changes to disk + changeSet.apply(); + // changeSet.applyAndSave(); + } +} diff --git a/codemod_core/example/bin/is_even_or_odd_suggestor.dart b/codemod_core/example/bin/is_even_or_odd_suggestor.dart new file mode 100644 index 0000000..f61ac9b --- /dev/null +++ b/codemod_core/example/bin/is_even_or_odd_suggestor.dart @@ -0,0 +1,23 @@ +import 'package:codemod_core/codemod_core.dart'; +import 'package:glob/glob.dart'; +import 'package:path/path.dart'; + +// ignore: avoid_relative_lib_imports +import '../lib/src/suggestors/is_even_or_odd_suggestor.dart'; + +/// To run this example: +/// $ cd example/bin +/// $ dart is_even_or_odd_suggester.dart +void main(List args) async { + final paths = filePathsFromGlob( + Glob(join('fixtures', 'is_even_or_odd_suggestor', '**.dart')), + ); + final pg = PatchGenerator([IsEvenOrOddSuggestor().call]); + final changeSets = pg.generate(paths: paths); + + await for (final changeSet in changeSets) { + /// Change .apply to .applyAndSave to write the changes to disk + changeSet.apply(); + // changeSet.applyAndSave(); + } +} diff --git a/codemod_core/example/bin/license_header_inserter.dart b/codemod_core/example/bin/license_header_inserter.dart new file mode 100644 index 0000000..cd2ccee --- /dev/null +++ b/codemod_core/example/bin/license_header_inserter.dart @@ -0,0 +1,39 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example +/// $ dart license_header_inserter.dart +library dart_codemod.example.license_header_inserter; + +import 'package:codemod_core/codemod_core.dart'; +import 'package:glob/glob.dart'; +import 'package:path/path.dart'; + +// ignore: avoid_relative_lib_imports +import '../lib/src/suggestors/license_header_inserter.dart'; + +void main(List args) async { + final paths = filePathsFromGlob( + Glob(join('fixtures', 'license_header', '**.dart')), + ); + final pg = PatchGenerator([licenseHeaderInserter]); + final changeSets = pg.generate(paths: paths); + + await for (final changeSet in changeSets) { + /// Change .apply to .applyAndSave to write the changes to disk + changeSet.apply(); + // changeSet.applyAndSave(); + } +} diff --git a/codemod_core/example/bin/regex_substituter.dart b/codemod_core/example/bin/regex_substituter.dart new file mode 100644 index 0000000..f46f768 --- /dev/null +++ b/codemod_core/example/bin/regex_substituter.dart @@ -0,0 +1,36 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example +/// $ dart regex_substituter.dart +library dart_codemod.example.regex_substituter; + +import 'package:codemod_core/codemod_core.dart'; +import 'package:path/path.dart'; + +// ignore: avoid_relative_lib_imports +import '../lib/src/suggestors/regex_substituter.dart'; + +void main(List args) async { + final pg = PatchGenerator([regexSubstituter]); + final changeSets = pg + .generate(paths: [join('fixtures', 'regex_substituter', 'pubspec.yaml')]); + + await for (final changeSet in changeSets) { + /// Change .apply to .applyAndSave to write the changes to disk + changeSet.apply(); + // changeSet.applyAndSave(); + } +} diff --git a/codemod_core/example/class_rename.dart b/codemod_core/example/class_rename.dart new file mode 100644 index 0000000..28ec1f9 --- /dev/null +++ b/codemod_core/example/class_rename.dart @@ -0,0 +1,56 @@ +// ignore_for_file: avoid_print + +import 'package:codemod_core/src/patch_generator.dart'; +import 'package:codemod_core/src/suggestors/class_rename.dart'; +import 'package:codemod_core/src/suggestors/method_rename.dart'; +import 'package:codemod_core/src/suggestors/variable_rename.dart'; +import 'package:codemod_core/src/utility/name_generator.dart'; +import 'package:dcli/dcli.dart'; +import 'package:path/path.dart'; + +void main(List args) async { + final pg = PatchGenerator([ + ClassRename((name) => ClassReplacer().replace(name)).call, + VariableRename('existing', 'different').call, + MethodRename('aMethod', 'aDifferent').call + ]); + + final fixtures = join(DartProject.self.pathToExampleDir, 'fixtures'); + + const libraryName = 'class_for_rename.dart'; + final testLibrary = join(fixtures, libraryName); + + final changeSetStream = pg.generate(paths: [testLibrary]); + await for (final changeSet in changeSetStream) { + print(changeSet.apply()); + } +} + +class ClassReplacer { + factory ClassReplacer() => _self; + + ClassReplacer._internal(); + static final ClassReplacer _self = ClassReplacer._internal(); + VariableNameGenerator gen = VariableNameGenerator(); + + Map replacementMap = {}; + + String replace(String existing) { + var value = replacementMap[existing]; + if (value == null) { + value = _generateName(existing); + replacementMap[existing] = value; + } + return value; + } + + String _generateName(String existing) { + var prefix = ''; + + /// retain the private nature of declarations + if (existing.startsWith('_')) { + prefix = '_'; + } + return '$prefix${gen.next()}'; + } +} diff --git a/codemod_core/example/example/lib/src/suggestors/deprecated_remover.dart b/codemod_core/example/example/lib/src/suggestors/deprecated_remover.dart new file mode 100644 index 0000000..cd376c8 --- /dev/null +++ b/codemod_core/example/example/lib/src/suggestors/deprecated_remover.dart @@ -0,0 +1,39 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example +/// $ dart deprecated_remover.dart +library dart_codemod.example.deprecated_remover; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:codemod_core/codemod_core.dart'; + +/// Suggestor that generates deletion patches for all deprecated declarations +/// (i.e. classes, constructors, variables, methods, etc.). +class DeprecatedRemover extends GeneralizingAstVisitor + with AstVisitingSuggestor { + static bool isDeprecated(AnnotatedNode node) => + node.metadata.any((m) => m.name.name.toLowerCase() == 'deprecated'); + + @override + void visitDeclaration(Declaration node) { + if (isDeprecated(node)) { + // Remove the node by replacing the span from its start offset to its end + // offset with an empty string. + yieldPatch('', node.offset, node.end); + } + } +} diff --git a/codemod_core/example/example/lib/src/suggestors/is_even_or_odd_suggestor.dart b/codemod_core/example/example/lib/src/suggestors/is_even_or_odd_suggestor.dart new file mode 100644 index 0000000..f66fa68 --- /dev/null +++ b/codemod_core/example/example/lib/src/suggestors/is_even_or_odd_suggestor.dart @@ -0,0 +1,32 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:codemod_core/codemod_core.dart'; + +/// Removes all modulus operations on the int type and refactors them to use +/// [int.isEven] and [int.isOdd]. +class IsEvenOrOddSuggestor extends GeneralizingAstVisitor + with AstVisitingSuggestor { + @override + bool shouldResolveAst(_) => true; + + @override + void visitBinaryExpression(BinaryExpression node) { + if (node.leftOperand is BinaryExpression && + node.rightOperand is IntegerLiteral) { + final left = node.leftOperand as BinaryExpression; + final right = node.rightOperand as IntegerLiteral; + if (left.operator.stringValue == '%' && + node.operator.stringValue == '==') { + if (left.leftOperand.staticType!.isDartCoreInt) { + if (right.value == 0) { + yieldPatch('.isEven', left.leftOperand.end, node.end); + } + if (right.value == 1) { + yieldPatch('.isOdd', left.leftOperand.end, node.end); + } + } + } + } + return super.visitBinaryExpression(node); + } +} diff --git a/codemod_core/example/example/lib/src/suggestors/license_header_inserter.dart b/codemod_core/example/example/lib/src/suggestors/license_header_inserter.dart new file mode 100644 index 0000000..c226f34 --- /dev/null +++ b/codemod_core/example/example/lib/src/suggestors/license_header_inserter.dart @@ -0,0 +1,46 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example +/// $ dart license_header_inserter.dart +library dart_codemod.example.license_header_inserter; + +import 'package:codemod_core/codemod_core.dart'; + +const String licenseHeader = ''' +// Lorem ispum license. +// 2018-2019 +'''; + +/// Suggestor that generates patches to insert a license header at the beginning +/// of every file that is missing such a header. +Stream licenseHeaderInserter(FileContext context) async* { + // Skip if license header already exists. + if (context.sourceText.trimLeft().startsWith(licenseHeader)) { + return; + } + + yield const Patch( + // Text to insert. + licenseHeader, + // Start offset. + // 0 means "insert at the beginning of the file." + 0, + // End offset. + // Using the same offset as the start offset here means that the patch + // is being inserted at this point instead of replacing a span of text. + 0, + ); +} diff --git a/codemod_core/example/example/lib/src/suggestors/regex_substituter.dart b/codemod_core/example/example/lib/src/suggestors/regex_substituter.dart new file mode 100644 index 0000000..b3acfaa --- /dev/null +++ b/codemod_core/example/example/lib/src/suggestors/regex_substituter.dart @@ -0,0 +1,40 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example +/// $ dart regex_substituter.dart +library dart_codemod.example.regex_substituter; + +import 'package:codemod_core/codemod_core.dart'; + +/// Pattern that matches a dependency version constraint line for the `codemod` +/// package, with the first capture group being the constraint. +final RegExp pattern = RegExp( + r'''^\s*codemod:\s*([\d\s"'<>=^.]+)\s*$''', + multiLine: true, +); + +/// The version constraint that `codemod` entries should be updated to. +const String targetConstraint = '^1.0.0'; + +Stream regexSubstituter(FileContext context) async* { + for (final match in pattern.allMatches(context.sourceText)) { + final line = match.group(0)!; + final constraint = match.group(1)!; + final updated = '${line.replaceFirst(constraint, targetConstraint)}\n'; + + yield Patch(updated, match.start, match.end); + } +} diff --git a/codemod_core/example/fixtures/class_for_rename.dart b/codemod_core/example/fixtures/class_for_rename.dart new file mode 100644 index 0000000..85d9bef --- /dev/null +++ b/codemod_core/example/fixtures/class_for_rename.dart @@ -0,0 +1,12 @@ +// ignore_for_file: unnecessary_statements + +class Existing { + Existing() { + Tom().existing; + } + void aMethod() {} +} + +class Tom { + Existing? existing; +} diff --git a/codemod_core/example/fixtures/deprecated_remover/deprecations.dart b/codemod_core/example/fixtures/deprecated_remover/deprecations.dart new file mode 100644 index 0000000..791a996 --- /dev/null +++ b/codemod_core/example/fixtures/deprecated_remover/deprecations.dart @@ -0,0 +1,12 @@ +// ignore: provide_deprecation_message +@deprecated +String foo = 'foo'; + +/// Class doc comment. +@Deprecated('2.0.0') +class Bar { + void method() {} +} + +// Not deprecated. +bool baz = true; diff --git a/codemod_core/example/fixtures/is_even_or_odd_suggestor/is_even_or_odd.dart b/codemod_core/example/fixtures/is_even_or_odd_suggestor/is_even_or_odd.dart new file mode 100644 index 0000000..29b1b5a --- /dev/null +++ b/codemod_core/example/fixtures/is_even_or_odd_suggestor/is_even_or_odd.dart @@ -0,0 +1,10 @@ +// ignore_for_file: use_is_even_rather_than_modulo +// Change to isEven + +bool foo = (250 + 2) % 2 == 0; + +// Change to isOdd +bool bar = (250 + 2) % 2 == 1; + +// No changes, not int modulus +bool baz = 25.0 % 2 == 0; diff --git a/codemod_core/example/fixtures/license_header/has_license.dart b/codemod_core/example/fixtures/license_header/has_license.dart new file mode 100644 index 0000000..a8eb3b1 --- /dev/null +++ b/codemod_core/example/fixtures/license_header/has_license.dart @@ -0,0 +1,7 @@ +// Lorem ispum license. +// 2018-2019 + +/// +library dart_codemod.example.license_header_fixtures.has_license; + +bool hasLicense() => false; diff --git a/codemod_core/example/fixtures/license_header/missing_license.dart b/codemod_core/example/fixtures/license_header/missing_license.dart new file mode 100644 index 0000000..c5e8e48 --- /dev/null +++ b/codemod_core/example/fixtures/license_header/missing_license.dart @@ -0,0 +1,4 @@ +/// +library dart_codemod.example.license_header_fixtures.missing_license; + +bool hasLicense() => false; diff --git a/codemod_core/example/fixtures/regex_substituter/pubspec.yaml b/codemod_core/example/fixtures/regex_substituter/pubspec.yaml new file mode 100644 index 0000000..7930654 --- /dev/null +++ b/codemod_core/example/fixtures/regex_substituter/pubspec.yaml @@ -0,0 +1,14 @@ +name: regex_substituter_example +private: true +version: 0.0.0 +description: regex example +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + codemod: + path: ../../ + codemod_core: + path: ../../../codemod_core diff --git a/codemod_core/example/lib/src/suggestors/deprecated_remover.dart b/codemod_core/example/lib/src/suggestors/deprecated_remover.dart new file mode 100644 index 0000000..cd376c8 --- /dev/null +++ b/codemod_core/example/lib/src/suggestors/deprecated_remover.dart @@ -0,0 +1,39 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example +/// $ dart deprecated_remover.dart +library dart_codemod.example.deprecated_remover; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:codemod_core/codemod_core.dart'; + +/// Suggestor that generates deletion patches for all deprecated declarations +/// (i.e. classes, constructors, variables, methods, etc.). +class DeprecatedRemover extends GeneralizingAstVisitor + with AstVisitingSuggestor { + static bool isDeprecated(AnnotatedNode node) => + node.metadata.any((m) => m.name.name.toLowerCase() == 'deprecated'); + + @override + void visitDeclaration(Declaration node) { + if (isDeprecated(node)) { + // Remove the node by replacing the span from its start offset to its end + // offset with an empty string. + yieldPatch('', node.offset, node.end); + } + } +} diff --git a/codemod_core/example/lib/src/suggestors/is_even_or_odd_suggestor.dart b/codemod_core/example/lib/src/suggestors/is_even_or_odd_suggestor.dart new file mode 100644 index 0000000..f66fa68 --- /dev/null +++ b/codemod_core/example/lib/src/suggestors/is_even_or_odd_suggestor.dart @@ -0,0 +1,32 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:codemod_core/codemod_core.dart'; + +/// Removes all modulus operations on the int type and refactors them to use +/// [int.isEven] and [int.isOdd]. +class IsEvenOrOddSuggestor extends GeneralizingAstVisitor + with AstVisitingSuggestor { + @override + bool shouldResolveAst(_) => true; + + @override + void visitBinaryExpression(BinaryExpression node) { + if (node.leftOperand is BinaryExpression && + node.rightOperand is IntegerLiteral) { + final left = node.leftOperand as BinaryExpression; + final right = node.rightOperand as IntegerLiteral; + if (left.operator.stringValue == '%' && + node.operator.stringValue == '==') { + if (left.leftOperand.staticType!.isDartCoreInt) { + if (right.value == 0) { + yieldPatch('.isEven', left.leftOperand.end, node.end); + } + if (right.value == 1) { + yieldPatch('.isOdd', left.leftOperand.end, node.end); + } + } + } + } + return super.visitBinaryExpression(node); + } +} diff --git a/codemod_core/example/lib/src/suggestors/license_header_inserter.dart b/codemod_core/example/lib/src/suggestors/license_header_inserter.dart new file mode 100644 index 0000000..c226f34 --- /dev/null +++ b/codemod_core/example/lib/src/suggestors/license_header_inserter.dart @@ -0,0 +1,46 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example +/// $ dart license_header_inserter.dart +library dart_codemod.example.license_header_inserter; + +import 'package:codemod_core/codemod_core.dart'; + +const String licenseHeader = ''' +// Lorem ispum license. +// 2018-2019 +'''; + +/// Suggestor that generates patches to insert a license header at the beginning +/// of every file that is missing such a header. +Stream licenseHeaderInserter(FileContext context) async* { + // Skip if license header already exists. + if (context.sourceText.trimLeft().startsWith(licenseHeader)) { + return; + } + + yield const Patch( + // Text to insert. + licenseHeader, + // Start offset. + // 0 means "insert at the beginning of the file." + 0, + // End offset. + // Using the same offset as the start offset here means that the patch + // is being inserted at this point instead of replacing a span of text. + 0, + ); +} diff --git a/codemod_core/example/lib/src/suggestors/regex_substituter.dart b/codemod_core/example/lib/src/suggestors/regex_substituter.dart new file mode 100644 index 0000000..b3acfaa --- /dev/null +++ b/codemod_core/example/lib/src/suggestors/regex_substituter.dart @@ -0,0 +1,40 @@ +// Copyright 2019 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. + +/// To run this example: +/// $ cd example +/// $ dart regex_substituter.dart +library dart_codemod.example.regex_substituter; + +import 'package:codemod_core/codemod_core.dart'; + +/// Pattern that matches a dependency version constraint line for the `codemod` +/// package, with the first capture group being the constraint. +final RegExp pattern = RegExp( + r'''^\s*codemod:\s*([\d\s"'<>=^.]+)\s*$''', + multiLine: true, +); + +/// The version constraint that `codemod` entries should be updated to. +const String targetConstraint = '^1.0.0'; + +Stream regexSubstituter(FileContext context) async* { + for (final match in pattern.allMatches(context.sourceText)) { + final line = match.group(0)!; + final constraint = match.group(1)!; + final updated = '${line.replaceFirst(constraint, targetConstraint)}\n'; + + yield Patch(updated, match.start, match.end); + } +} diff --git a/codemod_core/example/readme.md b/codemod_core/example/readme.md new file mode 100644 index 0000000..cf51d72 --- /dev/null +++ b/codemod_core/example/readme.md @@ -0,0 +1,19 @@ +This directory contains several example codemods: + +- [License header inserter](/example/license_header_inserter.dart) + + A simple example that inserts license text at the top of a file. + +- [Regex substituter](/example/regex_substituter.dart) + + A simple example that uses a RegExp to make replacements over all matches. + +- [Remove deprecated elements](/example/deprecated_remover.dart) + + Uses an `AstVisitor` from `package:analyzer` to remove all elements annotated + with `@deprecated`. + +- [Suggest `isEven` or `isOdd`](/example/is_even_or_odd_suggestor.dart) + + Uses an `AstVisitor` and fully resolves all source code information and types + for a more advanced modification. diff --git a/codemod_core/lib/codemod_core.dart b/codemod_core/lib/codemod_core.dart new file mode 100644 index 0000000..ad88c51 --- /dev/null +++ b/codemod_core/lib/codemod_core.dart @@ -0,0 +1,16 @@ +export 'src/aggregate.dart'; +export 'src/ast_visiting_suggestor.dart'; +export 'src/change_set.dart'; +export 'src/exceptions.dart'; +export 'src/file_context.dart'; +export 'src/logging.dart'; +export 'src/patch.dart'; +export 'src/patch_generator.dart'; +export 'src/suggestor.dart'; +export 'src/utility/file_query_util.dart' + show + filePathsFromGlob, + isDartHiddenFile, + isHiddenFile, + isNotDartHiddenFile, + isNotHiddenFile; diff --git a/lib/src/aggregate.dart b/codemod_core/lib/src/aggregate.dart similarity index 70% rename from lib/src/aggregate.dart rename to codemod_core/lib/src/aggregate.dart index bec3ae4..e94d123 100644 --- a/lib/src/aggregate.dart +++ b/codemod_core/lib/src/aggregate.dart @@ -12,10 +12,8 @@ import 'suggestor.dart'; /// ... /// ]), /// ); -Suggestor aggregate(Iterable suggestors) { - return (context) async* { - for (final suggestor in suggestors) { - yield* suggestor(context); - } - }; -} +Suggestor aggregate(Iterable suggestors) => (context) async* { + for (final suggestor in suggestors) { + yield* suggestor(context); + } + }; diff --git a/lib/src/ast_visiting_suggestor.dart b/codemod_core/lib/src/ast_visiting_suggestor.dart similarity index 86% rename from lib/src/ast_visiting_suggestor.dart rename to codemod_core/lib/src/ast_visiting_suggestor.dart index 78ce816..1e305db 100644 --- a/lib/src/ast_visiting_suggestor.dart +++ b/codemod_core/lib/src/ast_visiting_suggestor.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:analyzer/dart/ast/ast.dart'; -import 'package:codemod/src/file_context.dart'; import 'package:logging/logging.dart'; +import 'file_context.dart'; import 'patch.dart'; import 'suggestor.dart'; @@ -17,9 +17,6 @@ final _log = Logger('AstVisitingSuggestor'); /// locations in the source using the offsets provided by the [AstNode]s and /// tokens therein. /// -/// Note that this mixin provides an implementation of [generatePatches] that -/// should not need to be overridden except for performance optimization reasons -/// like avoiding analysis on certain files. /// /// By default, this operates on the unresolved AST. Subclasses that need a /// fully resolved AST (e.g. for static typing info) should override @@ -35,7 +32,8 @@ final _log = Logger('AstVisitingSuggestor'); /// with AstVisitingSuggestor { /// /// static bool isDeprecated(AnnotatedNode node) => -/// node.metadata.any((m) => m.name.name.toLowerCase() == 'deprecated'); +/// node.metadata.any((m) => m.name.name.toLowerCase() +/// == 'deprecated'); /// /// @override /// visitDeclaration(Declaration node) { @@ -51,19 +49,23 @@ mixin AstVisitingSuggestor on AstVisitor { /// The context helper for the file currently being visited. FileContext get context { - if (_context != null) return _context!; + if (_context != null) { + return _context!; + } throw StateError('context accessed outside of a visiting context. ' - 'Ensure that your suggestor only accesses `this.context` inside an AST visitor method.'); + '''Ensure that your suggestor only accesses `this.context` inside an AST visitor method.'''); } FileContext? _context; Stream call(FileContext context) async* { - if (shouldSkip(context)) return; + if (shouldSkip(context)) { + return; + } CompilationUnit unit; if (shouldResolveAst(context)) { - var result = await context.getResolvedUnit(); + final result = await context.getResolvedUnit(); if (result == null) { _log.warning( 'Could not get resolved unit for "${context.relativePath}"'); diff --git a/codemod_core/lib/src/change_set.dart b/codemod_core/lib/src/change_set.dart new file mode 100644 index 0000000..73c5dbc --- /dev/null +++ b/codemod_core/lib/src/change_set.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:source_span/source_span.dart'; + +import '../codemod_core.dart'; +import 'collision.dart'; + +/// List of [Patch]es to be applied to [sourceFile] +class ChangeSet { + ChangeSet(this.sourceFile, this.patches, {this.destinationPath}); + + ChangeSet.fromPatchs(this.sourceFile, List patches, + {this.destinationPath}) { + this.patches = + patches.map((patch) => SourcePatch.from(patch, sourceFile)).toList(); + } + late final List patches; + final SourceFile sourceFile; + final Path? destinationPath; + + /// If skipOverlapping was passed to the [apply] method then this will + /// contain a list of any overlapping patches + /// that were not applied. + final collisions = []; + + /// true if any patches were skipped + /// Can only be true if skipOverlapping was passed to the + /// [apply] method. + bool get skippedOverlapping => collisions.isEmpty; + + /// Returns the result of applying all of the [patches] + /// (insertions/deletions/replacements) to the contents of [sourceFile] + /// as a String. + /// [sourceFile] isn't modified as part of this operation. + /// + /// @see [applyAndSave] + /// + /// Throws an [Exception] if any two of the given [patches] overlap. + String apply({bool skipOverlapping = false}) { + final buffer = StringBuffer(); + final sortedPatches = + patches.map((p) => SourcePatch.from(p, sourceFile)).toList()..sort(); + + var lastEdgeOffset = 0; + late Patch prev; + for (final patch in sortedPatches) { + if (patch.startOffset < lastEdgeOffset) { + final collision = Collision(applying: patch, overlapping: prev); + + final cause = collision.description; + + if (skipOverlapping) { + logger.warning('Skipping overlapping patch: $cause'); + continue; + } + throw Exception(''' +Codemod terminated due to overlapping patch. +$cause + '''); + } + + // Write unmodified text from end of last patch to beginning of this patch + buffer + ..write(sourceFile.getText(lastEdgeOffset, patch.startOffset)) + // Write the patched text (and do nothing with the original text, + // which is effectively the same as replacing it) + ..write(patch.updatedText); + + lastEdgeOffset = patch.endOffset; + prev = patch; + } + + buffer.write(sourceFile.getText(lastEdgeOffset)); + return buffer.toString(); + } + + /// Applies all of the [patches] (insertions/deletions/replacements) to the + /// contents of [sourceFile] and writes the result to disk. + /// + /// Throws an [ArgumentError] if [sourceFile] has a null value for + /// [SourceFile.url], as it is required to open the file and write the new + /// contents. + /// if [destPath] is passed then then [sourceFile] is left unchanged + /// and the contents of [sourceFile] are written to [destPath] with + /// the patches applied. + void applyAndSave({String? destPath, bool skipOverlapping = false}) { + if (sourceFile.url == null) { + throw ArgumentError('sourceFile.url cannot be null'); + } + final updatedContents = apply(skipOverlapping: skipOverlapping); + + if (destPath == null) { + File.fromUri(sourceFile.url!).writeAsStringSync(updatedContents); + } else { + File(destPath) + ..createSync(recursive: true) + ..writeAsStringSync(updatedContents); + } + } +} diff --git a/codemod_core/lib/src/collision.dart b/codemod_core/lib/src/collision.dart new file mode 100644 index 0000000..b271a42 --- /dev/null +++ b/codemod_core/lib/src/collision.dart @@ -0,0 +1,22 @@ +import '../codemod_core.dart'; + +class Collision { + Collision({required this.applying, required this.overlapping}); + + /// The patch we were attempting to apply when an over + /// lapping patch was discovered. + /// This patch will not have been applied. + Patch applying; + + /// The overlapping patch. + /// This patch will have already been applied. + Patch overlapping; + + /// A human readable explaination of what occured. + String get description => 'Previous patch:\n' + ' $overlapping\n' + ' Updated text: ${overlapping.updatedText}\n' + 'Overlapping patch:\n' + ' $applying\n' + ' Updated text: ${applying.updatedText}\n'; +} diff --git a/codemod_core/lib/src/exceptions.dart b/codemod_core/lib/src/exceptions.dart new file mode 100644 index 0000000..468cac5 --- /dev/null +++ b/codemod_core/lib/src/exceptions.dart @@ -0,0 +1,16 @@ +class CodeMonException implements Exception { + CodeMonException(this.message); + String message; +} + +class PatchException extends CodeMonException { + PatchException(super.message); +} + +class InputException extends CodeMonException { + InputException(super.message); +} + +class QuittingException extends CodeMonException { + QuittingException() : super('The user choose the quit option'); +} diff --git a/lib/src/file_context.dart b/codemod_core/lib/src/file_context.dart similarity index 84% rename from lib/src/file_context.dart rename to codemod_core/lib/src/file_context.dart index c3c66f3..9926d0a 100644 --- a/lib/src/file_context.dart +++ b/codemod_core/lib/src/file_context.dart @@ -10,8 +10,16 @@ import 'package:source_span/source_span.dart'; import 'patch.dart'; /// A helper class for a file located at [path] that provides access to its -/// contents and analyzed formats like [CompilationUnit] and [LibraryElement]. +/// contents and analyzed formats like [CompilationUnit] and LibraryElement. class FileContext { + FileContext(this.path, this._analysisContextCollection, + {String? root, this.destPath}) + : root = root ?? p.current, + relativePath = p.relative(path, from: root) { + if (!p.isAbsolute(path)) { + throw ArgumentError.value(path, 'path', 'must be absolute.'); + } + } final AnalysisContextCollection _analysisContextCollection; /// This file's absolute path. @@ -25,13 +33,7 @@ class FileContext { /// Defaults to current working directory. final String root; - FileContext(this.path, this._analysisContextCollection, {String? root}) - : root = root ?? p.current, - relativePath = p.relative(path, from: root) { - if (!p.isAbsolute(path)) { - throw ArgumentError.value(path, 'path', 'must be absolute.'); - } - } + final String? destPath; /// A representation of this file that makes it easy to reference spans of /// text, which is useful for the creation of [SourcePatch]es. @@ -42,7 +44,7 @@ class FileContext { late final String sourceText = File(path).readAsStringSync(); /// Uses the analyzer to resolve and return the library result for this file, - /// which includes the [LibraryElement]. + /// which includes the LibraryElement. Future getResolvedLibrary() async { final result = await _analysisContextCollection .contextFor(path) @@ -70,19 +72,21 @@ class FileContext { CompilationUnit getUnresolvedUnit() { final result = parseString(content: sourceText, path: path, throwIfDiagnostics: false); - if (result.errors.isEmpty) return result.unit; + if (result.errors.isEmpty) { + return result.unit; + } // Errors thrown by parseString don't include the filename, and result in // the codemod halting without indicating which file it failed on. // To aid in debugging, we'll construct the error message the same way // parseString does, but also include the path to the file. - var buffer = StringBuffer(); - for (var error in result.errors) { - var location = result.lineInfo.getLocation(error.offset); + final buffer = StringBuffer(); + for (final error in result.errors) { + final location = result.lineInfo.getLocation(error.offset); buffer.writeln(' ${error.errorCode.name}: ${error.message} - ' '${location.lineNumber}:${location.columnNumber}'); } throw ArgumentError( - 'File "${relativePath}" produced diagnostics when parsed:\n$buffer'); + 'File "$relativePath" produced diagnostics when parsed:\n$buffer'); } } diff --git a/lib/src/logging.dart b/codemod_core/lib/src/logging.dart similarity index 91% rename from lib/src/logging.dart rename to codemod_core/lib/src/logging.dart index 71bafee..587a965 100644 --- a/lib/src/logging.dart +++ b/codemod_core/lib/src/logging.dart @@ -30,13 +30,13 @@ final Logger logger = Logger('codemod'); /// /// If [verbose] is true, additional information will be included in the log /// messages including stack traces, logger name, and extra newlines. -Function(LogRecord) logListener( +void Function(LogRecord) logListener( StringSink sink, { - bool? ansiOutputEnabled, - bool? verbose, + bool ansiOutputEnabled = false, + bool verbose = false, }) => - (record) => overrideAnsiOutput(ansiOutputEnabled == true, () { - _logListener(record, sink, verbose: verbose ?? false); + (record) => overrideAnsiOutput(ansiOutputEnabled, () { + _logListener(record, sink, verbose: verbose); }); void _logListener(LogRecord record, StringSink sink, {required bool verbose}) { @@ -78,7 +78,7 @@ void _logListener(LogRecord record, StringSink sink, {required bool verbose}) { /// Filter out the Logger names known to come from `codemod` and splits the /// header for levels >= WARNING. String _loggerName(LogRecord record, bool verbose) { - final knownNames = const [ + const knownNames = [ 'codemod', ]; return verbose || !knownNames.contains(record.loggerName) diff --git a/lib/src/patch.dart b/codemod_core/lib/src/patch.dart similarity index 93% rename from lib/src/patch.dart rename to codemod_core/lib/src/patch.dart index df95231..80fd655 100644 --- a/lib/src/patch.dart +++ b/codemod_core/lib/src/patch.dart @@ -12,9 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +// ignore_for_file: avoid_implementing_value_types + import 'dart:math' as math; import 'package:io/ansi.dart'; +import 'package:meta/meta.dart'; import 'package:quiver/core.dart'; import 'package:source_span/source_span.dart'; @@ -37,22 +40,22 @@ import 'logging.dart'; /// /// Also note that [endOffset] may be null, in which case it defaults to the end /// of the file. +@immutable class Patch { + const Patch(this.updatedText, this.startOffset, [this.endOffset]); final int startOffset; final int? endOffset; /// The value that would be written in place of the existing text across the - /// [sourceSpan]. + /// sourceSpan. /// /// An empty value here represents a deletion, whereas a non-empty value may /// represent either an insertion or a replacement. final String updatedText; - Patch(this.updatedText, this.startOffset, [this.endOffset]); - @override - bool operator ==(other) => + bool operator ==(Object other) => other is Patch && startOffset == other.startOffset && endOffset == other.endOffset && @@ -69,7 +72,14 @@ class Patch { /// [sourceFile] in order to enable the application of this patch to that file. /// /// This class also includes text rendering utilities for use in a CLI. +@immutable class SourcePatch implements Patch, Comparable { + const SourcePatch(this.sourceFile, this.sourceSpan, this.updatedText); + + SourcePatch.from(Patch patch, SourceFile sourceFile) + : this(sourceFile, sourceFile.span(patch.startOffset, patch.endOffset), + patch.updatedText); + /// The original source file upon which this patch represents a change. final SourceFile sourceFile; @@ -84,17 +94,11 @@ class SourcePatch implements Patch, Comparable { @override final String updatedText; - SourcePatch(this.sourceFile, this.sourceSpan, this.updatedText); - - SourcePatch.from(Patch patch, SourceFile sourceFile) - : this(sourceFile, sourceFile.span(patch.startOffset, patch.endOffset), - patch.updatedText); - @override int compareTo(SourcePatch other) => sourceSpan.compareTo(other.sourceSpan); @override - bool operator ==(other) => + bool operator ==(Object other) => other is SourcePatch && sourceSpan == other.sourceSpan && updatedText == other.updatedText; @@ -177,12 +181,11 @@ class SourcePatch implements Patch, Comparable { 'startContextLineNumber': startContextLineNumber, 'endContextLineNumber': endContextLineNumber, }; - logger.fine('diff sizing:\n' + - diffSizingDebug.keys - .map((k) => '\t$k: ${diffSizingDebug[k]}') - .join('\n')); - logger.fine('old text:\n${sourceSpan.text}'); - logger.fine('new text:\n$updatedText'); + logger + ..fine( + '''diff sizing:\n${diffSizingDebug.keys.map((k) => '\t$k: ${diffSizingDebug[k]}').join('\n')}''') + ..fine('old text:\n${sourceSpan.text}') + ..fine('new text:\n$updatedText'); final buffer = StringBuffer(); final sourceFileLines = sourceFile.getText(0).split('\n'); @@ -232,7 +235,7 @@ class SourcePatch implements Patch, Comparable { if (startLine == endLine - 1) { return '${sourceSpan.sourceUrl}:${startLine + 1}'; } - return '${sourceSpan.sourceUrl}:${startLine + 1}-${endLine}'; + return '${sourceSpan.sourceUrl}:${startLine + 1}-$endLine'; } @override diff --git a/codemod_core/lib/src/patch_generator.dart b/codemod_core/lib/src/patch_generator.dart new file mode 100644 index 0000000..1142c20 --- /dev/null +++ b/codemod_core/lib/src/patch_generator.dart @@ -0,0 +1,106 @@ +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:path/path.dart'; + +import 'change_set.dart'; +import 'exceptions.dart'; +import 'file_context.dart'; +import 'logging.dart'; +import 'patch.dart'; +import 'suggestor.dart'; + +typedef Path = String; + +/// The [PatchGenerator] is the main entry point to codemod_core. +/// +/// The [PatchGenerator] creates [ChangeSet]s based on a set of +/// [Suggestor]s that you supply. +/// +/// You can then 'apply' those changes to the Dart Libraries. +/// ```dart +/// void main(List args) async { +/// /// Identify the that we want to add a license header to +/// var paths = filePathsFromGlob( +/// Glob(join('fixtures', 'license_header', '**.dart')), +/// ); +/// +/// /// Instantiate the [PatchGenerator] with the set of +/// /// Suggestors we are going to using to generate [ChangeSet]s. +/// var pg = PatchGenerator([licenseHeaderInserter]); +/// +/// /// Generate the [ChangeSet]s. +/// var changeSets = pg.generate(paths: paths); +/// +/// /// Apply the [ChangeSet] to the code. +/// await for (var changeSet in changeSets) { +/// /// Change .apply to .applyAndSave to write the changes to disk +/// /// overwritting the existing .dart source files. +/// /// --------------------------------------- +/// /// WARNING: backup your source first!!!! +/// /// --------------------------------------- +/// var patchedSource = changeSet.apply(); +/// // changeSet.applyAndSave(); +/// } +/// } +/// ```dart +/// +class PatchGenerator { + PatchGenerator(this.suggestors); + + Iterable suggestors; + + /// Generates a [Stream] of [ChangeSet] objects based on the passed + /// [suggestors] that need to be applied to the source. + /// Throws a [PatchException] or any exception thrown by the [Suggestor]s. + Stream generate( + {required Iterable paths, List? destPaths}) async* { + _validateArgs(paths, destPaths); + + final canonicalizedPaths = paths.map(canonicalize).toList(); + + final collection = + AnalysisContextCollection(includedPaths: canonicalizedPaths); + + for (var i = 0; i < canonicalizedPaths.length; i++) { + final canonicalizedPath = canonicalizedPaths[i]; + + // test goes here + // and here. + final context = FileContext(canonicalizedPath, collection, + destPath: destPaths == null ? null : destPaths[i]); + + final patches = []; + for (final suggestor in suggestors) { + logger.fine('file: ${context.relativePath}'); + try { + final patchSet = await suggestor(context) + .map((p) => SourcePatch.from(p, context.sourceFile)) + .toList(); + for (final patch in patchSet) { + if (patch.isNoop) { + // Patch suggested, but without any changes. This is probably an + // error in the suggestor implementation. + logger.severe('Empty patch suggested: $patch'); + throw PatchException( + '''Empty patch suggested: $patch - this is probably a bug a sugestor'''); + } + } + patches.addAll(patchSet); + // ignore: avoid_catches_without_on_clauses + } catch (e, stackTrace) { + logger.severe( + 'Suggestor.generatePatches() threw unexpectedly.', e, stackTrace); + rethrow; + } + } + yield ChangeSet(context.sourceFile, patches, + destinationPath: context.destPath); + } + } + + void _validateArgs(Iterable paths, Iterable? destPaths) { + assert( + destPaths == null || paths.length == destPaths.length, + 'number of destPaths must be equal to the number of filePaths', + ); + } +} diff --git a/lib/src/suggestor.dart b/codemod_core/lib/src/suggestor.dart similarity index 96% rename from lib/src/suggestor.dart rename to codemod_core/lib/src/suggestor.dart index e0ff05e..ed8b104 100644 --- a/lib/src/suggestor.dart +++ b/codemod_core/lib/src/suggestor.dart @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +// ignore_for_file: comment_references + import 'aggregate.dart'; import 'ast_visiting_suggestor.dart'; import 'file_context.dart'; import 'patch.dart'; -import 'run_interactive_codemod.dart' - show runInteractiveCodemod, runInteractiveCodemodSequence; /// Function signature representing the core driver of a "codemod" (code /// modification). diff --git a/codemod_core/lib/src/suggestors/class_rename.dart b/codemod_core/lib/src/suggestors/class_rename.dart new file mode 100644 index 0000000..be8a505 --- /dev/null +++ b/codemod_core/lib/src/suggestors/class_rename.dart @@ -0,0 +1,39 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../../codemod_core.dart'; + +typedef Replace = String Function(String existing); + +/// Suggestor that renames a class +class ClassRename extends GeneralizingAstVisitor + with AstVisitingSuggestor { + ClassRename(this.replace); + + Replace replace; + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + // if (isMatching(node)) { + // yieldPatch(newClassName, node.offset, node.end); + // } + super.visitSimpleIdentifier(node); + } + + // The actual class declaration + @override + void visitClassDeclaration(ClassDeclaration node) { + final replacement = replace(node.name.lexeme); + yieldPatch(replacement, node.name.offset, node.name.end); + super.visitClassDeclaration(node); + } + + @override + void visitVariableDeclaration(VariableDeclaration node) { + // if (node.runtimeType.toString() == existingClassName) { + final replacement = replace(node.name.lexeme); + yieldPatch(replacement, node.name.offset, node.name.end); + //} + super.visitVariableDeclaration(node); + } +} diff --git a/codemod_core/lib/src/suggestors/method_rename.dart b/codemod_core/lib/src/suggestors/method_rename.dart new file mode 100644 index 0000000..e40d9ef --- /dev/null +++ b/codemod_core/lib/src/suggestors/method_rename.dart @@ -0,0 +1,36 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../../codemod_core.dart'; + +/// Suggestor that renames a variables and fields +class MethodRename extends GeneralizingAstVisitor + with AstVisitingSuggestor { + MethodRename(this.existingName, this.newName); + + String existingName; + String newName; + + bool isMatching(NamedCompilationUnitMember node) => + node.name.value() == existingName; + + @override + void visitMethodDeclaration(MethodDeclaration node) { + if (node.name.lexeme == existingName) { + yieldPatch(newName, node.name.offset, node.name.end); + } + super.visitMethodDeclaration(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + _patch(node.methodName); + super.visitMethodInvocation(node); + } + + void _patch(SimpleIdentifier node) { + if (node.name == existingName) { + yieldPatch(newName, node.offset, node.end); + } + } +} diff --git a/codemod_core/lib/src/suggestors/variable_rename.dart b/codemod_core/lib/src/suggestors/variable_rename.dart new file mode 100644 index 0000000..5f5ed7d --- /dev/null +++ b/codemod_core/lib/src/suggestors/variable_rename.dart @@ -0,0 +1,29 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import '../../codemod_core.dart'; + +/// Suggestor that renames a variables and fields +class VariableRename extends GeneralizingAstVisitor + with AstVisitingSuggestor { + VariableRename(this.existingName, this.newName); + + String existingName; + String newName; + + bool isMatching(NamedCompilationUnitMember node) => + node.name.value() == existingName; + + @override + void visitVariableDeclaration(VariableDeclaration node) { + _patch(node.name); + super.visitVariableDeclaration(node); + } + + void _patch(Token node) { + if (node.lexeme == existingName) { + yieldPatch(newName, node.offset, node.end); + } + } +} diff --git a/lib/src/file_query_util.dart b/codemod_core/lib/src/utility/file_query_util.dart similarity index 100% rename from lib/src/file_query_util.dart rename to codemod_core/lib/src/utility/file_query_util.dart diff --git a/codemod_core/lib/src/utility/name_generator.dart b/codemod_core/lib/src/utility/name_generator.dart new file mode 100644 index 0000000..87496d6 --- /dev/null +++ b/codemod_core/lib/src/utility/name_generator.dart @@ -0,0 +1,40 @@ +class NameGenerator { + static String nextLetter = 'a'; + static String currentBase = ''; + + String next() { + final generated = '$currentBase$nextLetter'; + + if (nextLetter == 'z') { + nextLetter = 'a'; + currentBase = generated; + } else { + nextLetter = String.fromCharCode(nextLetter.codeUnitAt(0) + 1); + } + return generated; + } +} + +class VariableNameGenerator { + int _counter = 0; + + String next() { + final variableName = _generateVariableName(_counter); + _counter++; + return variableName; + } + + String _generateVariableName(int index) { + const base = 26; // Number of letters in the alphabet + var variableName = ''; + + do { + final remainder = index % base; + variableName = + String.fromCharCode('a'.codeUnitAt(0) + remainder) + variableName; + index = (index ~/ base) - 1; + } while (index >= 0); + + return variableName; + } +} diff --git a/codemod_core/lib/src/version/version.g.dart b/codemod_core/lib/src/version/version.g.dart new file mode 100644 index 0000000..8fe88c5 --- /dev/null +++ b/codemod_core/lib/src/version/version.g.dart @@ -0,0 +1,3 @@ +/// GENERATED BY pub_release do not modify. +/// codemod_core version +String packageVersion = '1.0.1'; diff --git a/codemod_core/pubspec.yaml b/codemod_core/pubspec.yaml new file mode 100644 index 0000000..94ea154 --- /dev/null +++ b/codemod_core/pubspec.yaml @@ -0,0 +1,24 @@ +name: codemod_core +version: 1.1.0 +homepage: https://github.com/Workiva/dart_codemod +publish_to: https://onepub.dev/api/jbbxpsdavu/ +description: A sample command-line application. +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + analyzer: ^6.0.0 + glob: ^2.1.2 + io: ^1.0.4 + lint_hard: ^4.0.0 + logging: ^1.2.0 + meta: ^1.11.0 + mockito: ^5.4.2 + path: ^1.8.3 + quiver: ^3.2.1 + source_span: ^1.10.0 + stack_trace: ^1.11.1 +dev_dependencies: + dcli: ^3.2.0 + lints: ^2.0.0 + test: ^1.21.0 + test_descriptor: ^2.0.1 diff --git a/codemod_core/test/src/utility/name_generator_test.dart b/codemod_core/test/src/utility/name_generator_test.dart new file mode 100644 index 0000000..49c3cb3 --- /dev/null +++ b/codemod_core/test/src/utility/name_generator_test.dart @@ -0,0 +1,18 @@ +// ignore_for_file: avoid_print + +import 'package:codemod_core/src/utility/name_generator.dart'; +import 'package:test/test.dart'; + +void main() { + test('name generator ...', () async { + final gen = VariableNameGenerator(); + var last = ''; + for (var i = 0; i < 1000; i++) { + last = gen.next(); + if (i == 0) { + expect(last, equals('a')); + } + } + expect(last, equals('all')); + }); +} diff --git a/test/util_test.dart b/codemod_core/test/util_test.dart similarity index 55% rename from test/util_test.dart rename to codemod_core/test/util_test.dart index 32cb0fd..4ccae29 100644 --- a/test/util_test.dart +++ b/codemod_core/test/util_test.dart @@ -15,21 +15,16 @@ @TestOn('vm') import 'dart:io'; +import 'package:codemod_core/codemod_core.dart'; import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; import 'package:source_span/source_span.dart'; import 'package:test/test.dart'; -import 'package:codemod/src/patch.dart'; -import 'package:codemod/src/util.dart'; - -import 'util_test.mocks.dart'; - @GenerateMocks([Stdout]) void main() { group('Utils', () { group('applyPatches()', () { - final sourceContents = ''' + const sourceContents = ''' line 1; line 2; line 3; @@ -41,20 +36,20 @@ line 5;'''; // line 1; // <<< // line 1; - final insertion = Patch('', 2, 2); + const insertion = Patch('', 2, 2); // >>> // line 2; // line 3; // <<< // line 3; - final replacement = Patch('', 9, 17); + const replacement = Patch('', 9, 17); // >>> // line 4; // <<< // l4; - final deletion = Patch('', 25, 29); + const deletion = Patch('', 25, 29); // >>> // line 4; @@ -65,14 +60,14 @@ line 5;'''; final eofDeletion = Patch('', sourceFile.length - 'line 5;'.length); // Patch that overlaps with [replacement]. - final overlapsReplacement = Patch('NOPE', 11, 12); + const overlapsReplacement = Patch('NOPE', 11, 12); test('returns original source if patches is empty', () { - expect(applyPatches(sourceFile, []), sourceContents); + expect(ChangeSet(sourceFile, []).apply(), sourceContents); }); test('applies an insertion', () { - expect(applyPatches(sourceFile, [insertion]), ''' + expect(ChangeSet.fromPatchs(sourceFile, [insertion]).apply(), ''' line 1; line 2; line 3; @@ -81,7 +76,7 @@ line 5;'''); }); test('applies a replacement', () { - expect(applyPatches(sourceFile, [replacement]), ''' + expect(ChangeSet.fromPatchs(sourceFile, [replacement]).apply(), ''' line 1; line 3; line 4; @@ -89,7 +84,7 @@ line 5;'''); }); test('applies a deletion', () { - expect(applyPatches(sourceFile, [deletion]), ''' + expect(ChangeSet.fromPatchs(sourceFile, [deletion]).apply(), ''' line 1; line 2; line 3; @@ -98,7 +93,7 @@ line 5;'''); }); test('applies a deletion at end of file', () { - expect(applyPatches(sourceFile, [eofDeletion]), ''' + expect(ChangeSet.fromPatchs(sourceFile, [eofDeletion]).apply(), ''' line 1; line 2; line 3; @@ -107,7 +102,10 @@ line 4; }); test('applies multiple patches', () { - expect(applyPatches(sourceFile, [insertion, replacement, deletion]), ''' + expect( + ChangeSet.fromPatchs(sourceFile, [insertion, replacement, deletion]) + .apply(), + ''' line 1; line 3; l4; @@ -115,7 +113,10 @@ line 5;'''); }); test('applies patches in order from beginning to end', () { - expect(applyPatches(sourceFile, [deletion, insertion, replacement]), ''' + expect( + ChangeSet.fromPatchs(sourceFile, [deletion, insertion, replacement]) + .apply(), + ''' line 1; line 3; l4; @@ -124,31 +125,10 @@ line 5;'''); test('throws if any two patches overlap', () { expect( - () => applyPatches(sourceFile, [replacement, overlapsReplacement]), + () => ChangeSet.fromPatchs( + sourceFile, [replacement, overlapsReplacement]).apply(), throwsException); }); }); - - group('calculateDiffSize()', () { - test('returns 10 if stdout does not have a terminal', () { - final mockStdout = MockStdout(); - when(mockStdout.hasTerminal).thenReturn(false); - expect(calculateDiffSize(mockStdout), 10); - }); - - test('returns 10 if # of terminal lines is too small', () { - final mockStdout = MockStdout(); - when(mockStdout.hasTerminal).thenReturn(true); - when(mockStdout.terminalLines).thenReturn(15); - expect(calculateDiffSize(mockStdout), 10); - }); - - test('returns 10 less than available # of terminal lines', () { - final mockStdout = MockStdout(); - when(mockStdout.hasTerminal).thenReturn(true); - when(mockStdout.terminalLines).thenReturn(50); - expect(calculateDiffSize(mockStdout), 40); - }); - }); }); } diff --git a/example/regex_substituter_fixtures/pubspec.yaml b/example/regex_substituter_fixtures/pubspec.yaml deleted file mode 100644 index 7f5ddd1..0000000 --- a/example/regex_substituter_fixtures/pubspec.yaml +++ /dev/null @@ -1,8 +0,0 @@ -name: regex_substituter_example -private: true -version: 0.0.0 - -dependencies: - codemod: ^0.1.0 -environment: - sdk: '>=2.11.0 <3.0.0'