diff --git a/bin/intl_quick.dart b/bin/intl_quick.dart new file mode 100644 index 00000000..9ab89709 --- /dev/null +++ b/bin/intl_quick.dart @@ -0,0 +1,15 @@ +// Copyright 2021 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export 'package:over_react_codemod/src/executables/intl_quick_migration.dart'; diff --git a/lib/src/executables/intl_quick_migration.dart b/lib/src/executables/intl_quick_migration.dart new file mode 100644 index 00000000..22eed0ce --- /dev/null +++ b/lib/src/executables/intl_quick_migration.dart @@ -0,0 +1,153 @@ +// Copyright 2021 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:args/args.dart'; +import 'package:codemod/codemod.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:over_react_codemod/src/intl_suggestors/intl_importer.dart'; +import 'package:over_react_codemod/src/intl_suggestors/intl_messages.dart'; +import 'package:over_react_codemod/src/intl_suggestors/utils.dart'; +import 'package:path/path.dart' as p; + +typedef Migrator = Stream Function(FileContext); + +final FileSystem fs = const LocalFileSystem(); + +final parser = ArgParser() + ..addFlag( + 'help', + abbr: 'h', + negatable: false, + help: 'Prints this help output.', + ) + ..addOption( + 'offset', + abbr: 'o', + help: + 'The character offset of a position within the string we want to migrate', + ) + ..addFlag( + 'verbose', + abbr: 'v', + negatable: false, + help: 'Outputs all logging to stdout/stderr.', + ); + +late ArgResults parsedArgs; + +void main(List args) async { + parsedArgs = parser.parse(args); + if (parsedArgs['help'] as bool) { + printUsage(); + return; + } + + if (parsedArgs.rest.isEmpty) { + print('You have to specify a file'); + exit(1); + } + var intlPath = p.canonicalize(p.absolute((parsedArgs.rest.first))); + + await migratePackage( + fs.currentDirectory.path, intlPath, int.parse(parsedArgs['offset'])); +} + +void printUsage() { + stderr.writeln('Migrates a particular string to an intl message.'); + stderr.writeln(); + stderr.writeln('Usage:'); + stderr.writeln(' intl_quick [arguments]'); + stderr.writeln(); + stderr.writeln('Options:'); + stderr.writeln(parser.usage); +} + +/// Migrate files included in [paths] within [packagePath]. +/// +/// We expect [paths] to be absolute. +Future migratePackage(String packagePath, String path, int offset) async { + final packageName = p.basename(packagePath); + + final IntlMessages messages = IntlMessages(packageName, + directory: fs.currentDirectory, packagePath: packagePath); + + exitCode = await runMigrators( + [path], ['--yes-to-all'], messages, packageName, offset); + + messages.write(force: false); + // This will leave the intl.dart file unformatted, but that takes too long, so we'll just leave it out. +} + +Future runMigrators( + List packageDartPaths, + List codemodArgs, + IntlMessages messages, + String packageName, + int offset) async { + final constantStringMigrator = SingleStringMigrator(messages, offset, offset); + // The import migrator is extremely slow, probably looking at all the files. + final importMigrator = (FileContext context) => + intlImporter(context, packageName, messages.className); + + var result = await runInteractiveCodemodSequence( + packageDartPaths, [constantStringMigrator], + args: ['--yes-to-all'], defaultYes: true); + return result; +} + +// TODO: This shouldn't be in the executable file. +class SingleStringMigrator extends GeneralizingAstVisitor + with AstVisitingSuggestor { + final IntlMessages _messages; + int startPosition; + int endPosition; + + SingleStringMigrator(this._messages, this.startPosition, this.endPosition); + + @override + visitStringLiteral(StringLiteral node) { + // Assume this is a single character position and just check if it's within the string for now. + if (node.offset <= startPosition && node.end >= startPosition) { + migrateStringExpression(node); + } + super.visitStringLiteral(node); + } + + void migrateStringExpression(StringLiteral node) { + var stringForm = stringContent(node); + if (stringForm != null && stringForm.isNotEmpty) { + final functionCall = + _messages.syntax.getterCall(node, _messages.className); + final functionDef = + _messages.syntax.getterDefinition(node, _messages.className); + yieldPatch(functionCall, node.offset, node.end); + addMethodToClass(_messages, functionDef); + } else { + if (isValidStringInterpolationNode(node)) { + var interpolation = node as StringInterpolation; + final functionCall = _messages.syntax + .functionCall(interpolation, _messages.className, ''); + final functionDef = _messages.syntax + .functionDefinition(interpolation, _messages.className, ''); + yieldPatch(functionCall, interpolation.offset, interpolation.end); + addMethodToClass(_messages, functionDef); + } + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 65297da1..cbcc42a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ executables: rmui_preparation: rmui_bundle_update: intl_message_migration: + intl_quick: dependency_validator: ignore: - meta diff --git a/test/intl_suggestors/single_string_migrator_test.dart b/test/intl_suggestors/single_string_migrator_test.dart new file mode 100644 index 00000000..a39bba32 --- /dev/null +++ b/test/intl_suggestors/single_string_migrator_test.dart @@ -0,0 +1,80 @@ +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:over_react_codemod/src/executables/intl_quick_migration.dart'; +import 'package:over_react_codemod/src/intl_suggestors/intl_messages.dart'; +import 'package:test/test.dart'; + +import '../resolved_file_context.dart'; +import '../util.dart'; + +void main() { + final resolvedContext = SharedAnalysisContext.overReact; + + // Warm up analysis in a setUpAll so that if getting the resolved AST times out + // (which is more common for the WSD context), it fails here instead of failing the first test. + setUpAll(resolvedContext.warmUpAnalysis); + + group('Single string Migrator', () { + final FileSystem fs = MemoryFileSystem(); + late IntlMessages messages; + late SuggestorTester basicSuggestor; + + // Idempotency isn't a worry for this suggestor, and testing it throws off + // checking for duplicates, so disable it for these tests. + // TODO: Avoid duplicating this between test files. + Future testSuggestor( + {required String input, required String expectedOutput}) => + basicSuggestor( + input: input, + expectedOutput: expectedOutput, + testIdempotency: false); + + setUp(() async { + final Directory tmp = await fs.systemTempDirectory.createTemp(); + messages = IntlMessages('TestClass', directory: tmp); + messages.outputFile.createSync(recursive: true); + }); + + suggest(int characterPosition) { + basicSuggestor = getSuggestorTester( + SingleStringMigrator(messages, characterPosition, characterPosition), + resolvedContext: resolvedContext, + ); + } + + tearDown(() { + messages.delete(); + }); + + group('Constants', () { + test('standlalone variable', () async { + suggest(26); + await testSuggestor( + input: ''' + var foo = 'I am a user-visible constant'; + ''', + expectedOutput: ''' + var foo = TestClassIntl.iAmAUservisibleConstant; + ''', + ); + final expectedFileContent = + '\n static String get iAmAUservisibleConstant => Intl.message(\'I am a user-visible constant\', name: \'TestClassIntl_iAmAUservisibleConstant\');\n'; + expect(messages.messageContents(), expectedFileContent); + }); + + test('out of range', () async { + suggest(15); + await testSuggestor( + input: ''' + var foo = 'I am a user-visible constant'; + ''', + expectedOutput: ''' + var foo = 'I am a user-visible constant'; + ''', + ); + final expectedFileContent = ''; + expect(messages.messageContents(), expectedFileContent); + }); + }); + }); +}