| // Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:convert' show jsonDecode, JsonEncoder; |
| |
| import 'package:analyzer/dart/analysis/features.dart'; |
| import 'package:analyzer/dart/analysis/results.dart'; |
| import 'package:analyzer/dart/ast/ast.dart'; |
| import 'package:analyzer/file_system/file_system.dart'; |
| import 'package:analyzer/src/generated/source.dart'; |
| import 'package:analyzer_plugin/protocol/protocol_common.dart'; |
| import 'package:cli_util/cli_logging.dart'; |
| import 'package:meta/meta.dart'; |
| import 'package:nnbd_migration/nnbd_migration.dart'; |
| import 'package:nnbd_migration/src/front_end/charcodes.dart'; |
| import 'package:nnbd_migration/src/front_end/dartfix_listener.dart'; |
| import 'package:nnbd_migration/src/front_end/instrumentation_listener.dart'; |
| import 'package:nnbd_migration/src/front_end/migration_state.dart'; |
| import 'package:nnbd_migration/src/front_end/migration_summary.dart'; |
| import 'package:nnbd_migration/src/preview/http_preview_server.dart'; |
| import 'package:nnbd_migration/src/utilities/json.dart' as json; |
| import 'package:pub_semver/pub_semver.dart'; |
| import 'package:source_span/source_span.dart'; |
| import 'package:yaml/yaml.dart'; |
| |
| /// [NonNullableFix] visits each named type in a resolved compilation unit |
| /// and determines whether the associated variable or parameter can be null |
| /// then adds or removes a '?' trailing the named type as appropriate. |
| class NonNullableFix { |
| static final List<HttpPreviewServer?> _allServers = []; |
| |
| final Version _intendedMinimumSdkVersion; |
| |
| /// The internet address the server should bind to. Should be suitable for |
| /// passing to HttpServer.bind, i.e. either a [String] or an |
| /// [InternetAddress]. |
| final Object? bindAddress; |
| |
| final Logger _logger; |
| |
| final int? preferredPort; |
| |
| final DartFixListener listener; |
| |
| /// The root of the included paths. |
| /// |
| /// The included paths may contain absolute and relative paths, non-canonical |
| /// paths, and directory and file paths. The "root" is the deepest directory |
| /// which all included paths share. |
| final String includedRoot; |
| |
| /// If non-null, the path to which a machine-readable summary of migration |
| /// results should be written. |
| final String? summaryPath; |
| |
| final ResourceProvider resourceProvider; |
| |
| /// The HTTP server that serves the preview tool. |
| HttpPreviewServer? _server; |
| |
| String? authToken; |
| |
| InstrumentationListener? instrumentationListener; |
| |
| NullabilityMigrationAdapter? adapter; |
| |
| NullabilityMigration? migration; |
| |
| late Future<MigrationState> Function() rerunFunction; |
| |
| /// A list of the URLs corresponding to the included roots. |
| List<String>? previewUrls; |
| |
| /// A function which returns whether a file at a given path should be |
| /// migrated. |
| final bool Function(String?) shouldBeMigratedFunction; |
| |
| /// The set of files which are being considered for migration. |
| Iterable<String>? pathsToProcess; |
| |
| /// Completes when the server has been shutdown. |
| late Completer<void> serverIsShutdown; |
| |
| NonNullableFix(this.listener, this.resourceProvider, this.bindAddress, |
| this._logger, this.shouldBeMigratedFunction, |
| {List<String> included = const [], |
| this.preferredPort, |
| this.summaryPath, |
| required String sdkPath}) |
| : includedRoot = |
| _getIncludedRoot(included, listener.server!.resourceProvider), |
| _intendedMinimumSdkVersion = |
| _computeIntendedMinimumSdkVersion(resourceProvider, sdkPath) { |
| reset(); |
| } |
| |
| bool get isPreviewServerRunning => _server != null; |
| |
| /// In the package_config.json file, the patch number is omitted. |
| String get _intendedLanguageVersion => |
| '${_intendedMinimumSdkVersion.major}.${_intendedMinimumSdkVersion.minor}'; |
| |
| String get _intendedSdkVersionConstraint => |
| '>=$_intendedMinimumSdkVersion <3.0.0'; |
| |
| InstrumentationListener createInstrumentationListener( |
| {MigrationSummary? migrationSummary}) => |
| InstrumentationListener(migrationSummary: migrationSummary); |
| |
| Future<void> finalizeUnit(ResolvedUnitResult result) async { |
| migration!.finalizeInput(result); |
| } |
| |
| Future<MigrationState> finish() async { |
| var neededPackages = migration!.finish(); |
| final state = MigrationState(migration, includedRoot, listener, |
| instrumentationListener, neededPackages, shouldBeMigratedFunction); |
| await state.refresh(_logger, pathsToProcess); |
| return state; |
| } |
| |
| Future<void> prepareUnit(ResolvedUnitResult result) async { |
| migration!.prepareInput(result); |
| } |
| |
| /// Processes the non-source files of the package rooted at [pkgFolder]. |
| /// |
| /// This means updating the pubspec.yaml file, the package_config.json |
| /// file, and the analysis_options.yaml file, each only if necessary. |
| /// |
| /// [neededPackages] is a map whose keys are the names of packages that should |
| /// be depended upon by the package's pubspec, and whose values are the |
| /// minimum required versions of those packages. |
| void processPackage(Folder pkgFolder, Map<String, Version> neededPackages) { |
| var pubspecFile = pkgFolder.getChildAssumingFile('pubspec.yaml'); |
| if (!pubspecFile.exists) { |
| // If the pubspec file cannot be found, we do not attempt to change the |
| // Package Config file, nor the analysis options file. |
| return; |
| } |
| |
| _YamlFile pubspec; |
| try { |
| pubspec = _YamlFile._parseFrom(pubspecFile); |
| } on FileSystemException catch (e) { |
| _processPubspecException('read', pubspecFile.path, e); |
| return; |
| } on FormatException catch (e) { |
| _processPubspecException('parse', pubspecFile.path, e); |
| return; |
| } |
| |
| var updated = _processPubspec(pubspec, neededPackages); |
| if (updated) { |
| _processConfigFile(pkgFolder, pubspec); |
| } |
| } |
| |
| Future<void> processUnit(ResolvedUnitResult result) async { |
| migration!.processInput(result); |
| } |
| |
| Future<MigrationState> rerun() async { |
| reset(); |
| var state = await rerunFunction(); |
| return state; |
| } |
| |
| void reset() { |
| instrumentationListener = createInstrumentationListener( |
| migrationSummary: summaryPath == null |
| ? null |
| : MigrationSummary(summaryPath, resourceProvider, includedRoot)); |
| adapter = NullabilityMigrationAdapter(listener); |
| migration = NullabilityMigration(adapter, |
| permissive: true, instrumentation: instrumentationListener); |
| } |
| |
| void shutdownServer() { |
| if (_server != null) { |
| _server!.close(); |
| _server = null; |
| serverIsShutdown.complete(); |
| } |
| } |
| |
| Future<void> startPreviewServer( |
| MigrationState state, void Function() applyHook) async { |
| // This method may be called multiple times, for example during a re-run. |
| // But the preview server should only be started once. |
| if (_server == null) { |
| void wrappedApplyHookWithShutdown() { |
| shutdownServer(); |
| applyHook(); |
| } |
| |
| _server = HttpPreviewServer(state, rerun, wrappedApplyHookWithShutdown, |
| bindAddress, preferredPort, _logger); |
| _server!.serveHttp(); |
| _allServers.add(_server); |
| var serverHostname = await _server!.boundHostname; |
| var serverPort = await _server!.boundPort; |
| authToken = await _server!.authToken; |
| serverIsShutdown = Completer(); |
| |
| previewUrls = [ |
| // TODO(jcollins-g): Change protocol to only return a single string. |
| Uri( |
| scheme: 'http', |
| host: serverHostname, |
| port: serverPort, |
| path: state.pathMapper!.map(includedRoot), |
| queryParameters: {'authToken': authToken}).toString() |
| ]; |
| } |
| } |
| |
| /// Updates the Package Config file to specify a minimum Dart SDK version |
| /// which supports null safety. |
| void _processConfigFile(Folder pkgFolder, _YamlFile pubspec) { |
| var packageName = pubspec._getName(); |
| if (packageName == null) { |
| return; |
| } |
| |
| var packageConfigFile = pkgFolder |
| .getChildAssumingFolder('.dart_tool') |
| .getChildAssumingFile('package_config.json'); |
| |
| if (!packageConfigFile.exists) { |
| _processPackageConfigException( |
| 'Warning: Could not find the package configuration file.', |
| packageConfigFile.path); |
| return; |
| } |
| try { |
| var configText = packageConfigFile.readAsStringSync(); |
| var configMap = json.expectType<Map>(jsonDecode(configText), 'root'); |
| json.expectKey(configMap, 'configVersion'); |
| var configVersion = |
| json.expectType<int>(configMap['configVersion'], 'configVersion'); |
| if (configVersion != 2) { |
| _processPackageConfigException( |
| 'Warning: Unexpected package configuration file version ' |
| '$configVersion (expected version 2). Cannot update this file.', |
| packageConfigFile.path); |
| return; |
| } |
| json.expectKey(configMap, 'packages'); |
| var packagesList = |
| json.expectType<List>(configMap['packages'], 'packages'); |
| for (var package in packagesList) { |
| var packageMap = json.expectType<Map>(package, 'package'); |
| json.expectKey(packageMap, 'name'); |
| var name = json.expectType<String>(packageMap['name'], 'name'); |
| if (name != packageName) { |
| continue; |
| } |
| json.expectKey(packageMap, 'languageVersion'); |
| packageMap['languageVersion'] = _intendedLanguageVersion; |
| // Pub appears to always use a two-space indent. This will minimize the |
| // diff between the previous text and the new text. |
| var newText = '${JsonEncoder.withIndent(' ').convert(configMap)}\n'; |
| |
| // TODO(srawlins): This is inelegant. We add an "edit" which replaces |
| // the entire content of the package config file with new content, while |
| // it is likely that only 1 character has changed. I do not know of a |
| // JSON parser that yields SourceSpans, so that I may know the proper |
| // index. One idea, another hack, would be to write a magic string in |
| // place of the version number, encode to JSON, and find the index of |
| // the magic string. |
| var line = 0; |
| var offset = 0; |
| var edit = SourceEdit(offset, configText.length, newText); |
| listener.addSourceFileEdit( |
| 'enable Null Safety language feature', |
| Location(packageConfigFile.path, offset, newText.length, line, 0, |
| endLine: 0, endColumn: 0), |
| SourceFileEdit(packageConfigFile.path, 0, edits: [edit])); |
| } |
| } on FormatException catch (e) { |
| _processPackageConfigException( |
| 'Warning: Encountered an error parsing the package configuration ' |
| 'file: $e\n\nCannot update this file.', |
| packageConfigFile.path); |
| } |
| } |
| |
| void _processPackageConfigException(String prefix, String packageConfigPath, |
| [Object error = '']) { |
| // TODO(#42138): This should use [listener.addRecommendation] when that |
| // function is implemented. |
| print('''$prefix |
| $packageConfigPath |
| $error |
| |
| Be sure to run `pub get` before examining the results of the migration. |
| '''); |
| } |
| |
| /// Updates the pubspec.yaml file to specify a minimum Dart SDK version which |
| /// supports null safety. |
| /// |
| /// Return value indicates whether the user's `package_config.json` file |
| /// should be updated. |
| bool _processPubspec(_YamlFile pubspec, Map<String, Version> neededPackages) { |
| bool packageConfigNeedsUpdate = false; |
| bool packageDepsUpdated = false; |
| var pubspecMap = pubspec.content; |
| YamlNode? environmentOptions; |
| if (pubspecMap is YamlMap) { |
| environmentOptions = pubspecMap.nodes['environment']; |
| } |
| if (environmentOptions == null) { |
| var start = SourceLocation(0, line: 0, column: 0); |
| var content = ''' |
| environment: |
| sdk: '$_intendedSdkVersionConstraint' |
| '''; |
| pubspec._insertAfterParent( |
| SourceSpan(start, start, ''), content, listener); |
| packageConfigNeedsUpdate = true; |
| } else if (environmentOptions is YamlMap) { |
| if (_updatePubspecConstraint(pubspec, environmentOptions, 'sdk', |
| "'$_intendedSdkVersionConstraint'", _intendedMinimumSdkVersion)) { |
| packageConfigNeedsUpdate = true; |
| } |
| } else { |
| // Odd malformed pubspec. Leave it alone, but go ahead and update the |
| // package_config.json file. |
| packageConfigNeedsUpdate = true; |
| } |
| if (neededPackages.isNotEmpty) { |
| YamlNode? dependencies; |
| if (pubspecMap is YamlMap) { |
| dependencies = pubspecMap.nodes['dependencies']; |
| } |
| if (dependencies == null) { |
| var depLines = [ |
| for (var entry in neededPackages.entries) |
| ' ${entry.key}: ^${entry.value}' |
| ]; |
| var start = SourceLocation(0, line: 0, column: 0); |
| var content = ''' |
| dependencies: |
| ${depLines.join('\n')} |
| '''; |
| pubspec._insertAfterParent( |
| SourceSpan(start, start, ''), content, listener); |
| packageDepsUpdated = true; |
| } else if (dependencies is YamlMap) { |
| for (var neededPackage in neededPackages.entries) { |
| if (_updatePubspecConstraint(pubspec, dependencies, neededPackage.key, |
| '^${neededPackage.value}', neededPackage.value)) { |
| packageDepsUpdated = true; |
| } |
| } |
| } |
| } |
| if (packageDepsUpdated) { |
| listener.reportPubGetNeeded(neededPackages); |
| } |
| |
| return packageConfigNeedsUpdate; |
| } |
| |
| void _processPubspecException(String action, String pubspecPath, error) { |
| listener.client.onFatalError('''Failed to $action pubspec file |
| $pubspecPath |
| $error |
| |
| Manually update this file to enable the Null Safety language feature by |
| adding: |
| |
| environment: |
| sdk: '$_intendedSdkVersionConstraint'; |
| '''); |
| throw StateError('listener.reportFatalError should never return'); |
| } |
| |
| /// Updates a constraint in the given [pubspec] file. If [key] is found in |
| /// [map], and the corresponding value does has a minimum less than |
| /// [minimumVersion], it is updated to [fullVersionConstraint]. If it is not |
| /// found, then an entry is added. |
| /// |
| /// Return value indicates whether a change was made. |
| bool _updatePubspecConstraint(_YamlFile pubspec, YamlMap map, String key, |
| String fullVersionConstraint, Version minimumVersion) { |
| var node = map.nodes[key]; |
| if (node == null) { |
| var content = ''' |
| |
| $key: $fullVersionConstraint'''; |
| pubspec._insertAfterParent(map.span, content, listener); |
| return true; |
| } else if (node is YamlScalar) { |
| VersionConstraint currentConstraint; |
| if (node.value is String) { |
| currentConstraint = VersionConstraint.parse(node.value as String); |
| var invalidVersionMessage = |
| 'The current SDK constraint in pubspec.yaml is invalid. A ' |
| 'minimum version, such as ">=2.7.0", is required when launching ' |
| "'dart migrate'."; |
| if (currentConstraint is Version) { |
| // In this case, the constraint is an exact version, like 2.0.0. |
| _logger.stderr(invalidVersionMessage); |
| return false; |
| } else if (currentConstraint is VersionRange) { |
| if (currentConstraint.min == null) { |
| _logger.stderr(invalidVersionMessage); |
| return false; |
| } else if (currentConstraint.min! >= minimumVersion) { |
| // The current version constraint is already up to date. Do not |
| // edit. |
| return false; |
| } else { |
| // TODO(srawlins): This overwrites the current maximum version. In |
| // the uncommon situation that there is a special maximum, it should |
| // not. |
| pubspec._replaceSpan(node.span, fullVersionConstraint, listener); |
| return true; |
| } |
| } else { |
| // The constraint is something different, like a union, like |
| // '>=1.0.0 <2.0.0 >=3.0.0 <4.0.0', which is not valid. |
| _logger.stderr(invalidVersionMessage); |
| return false; |
| } |
| } else { |
| // Something is odd with the constraint we've found in pubspec.yaml; |
| // Best to leave it alone. |
| return false; |
| } |
| } else { |
| // Something is odd with the format of pubspec.yaml; best to leave it |
| // alone. |
| return false; |
| } |
| } |
| |
| /// Allows unit tests to shut down any rogue servers that have been started, |
| /// so that unit testing can complete. |
| @visibleForTesting |
| static void shutdownAllServers() { |
| for (var server in _allServers) { |
| try { |
| server!.close(); |
| } catch (_) {} |
| } |
| _allServers.clear(); |
| } |
| |
| static Version _computeIntendedMinimumSdkVersion( |
| ResourceProvider resourceProvider, String sdkPath) { |
| var versionFile = resourceProvider |
| .getFile(resourceProvider.pathContext.join(sdkPath, 'version')); |
| if (!versionFile.exists) { |
| throw StateError( |
| 'Could not find SDK version file at ${versionFile.path}'); |
| } |
| var sdkVersionString = versionFile.readAsStringSync().trim(); |
| var sdkVersion = Version.parse(sdkVersionString); |
| // Ideally, we would like to set the user's minimum SDK constraint to the |
| // version in which null safety was released to stable. But we only want to |
| // do so if we are sure that stable release exists. An easy way to check |
| // that is to see if the current SDK version is greater than or equal to the |
| // stable release of null safety. |
| var nullSafetyStableReleaseVersion = Feature.non_nullable.releaseVersion!; |
| if (sdkVersion >= nullSafetyStableReleaseVersion) { |
| // It is, so we can use it as the minimum SDK constraint. |
| return nullSafetyStableReleaseVersion; |
| } else { |
| // It isn't. This either means that null safety hasn't been released to |
| // stable yet (in which case it's definitely not safe to use |
| // `nullSafetyStableReleaseVersion` as a minimum SDK constraint), or it |
| // has been released but the user hasn't upgraded to it (in which case we |
| // don't want to use it as a minimum SDK constraint anyway, because we |
| // don't want to force the user to upgrade their SDK in order to be able |
| // to use their own package). Our next best option is to use the user's |
| // current SDK version as a minimum SDK constraint, assuming it's a proper |
| // beta release version. |
| if (sdkVersionString.contains('beta')) { |
| // It is, so we can use it. |
| return sdkVersion; |
| } else { |
| // It isn't. The user is probably either on a bleeding edge version of |
| // the SDK (e.g. `2.12.0-edge.<SHA>`), a dev version |
| // (e.g. `2.12.0-X.Y.dev`), or an internally built version |
| // (e.g. `2.12.0-<large number>`). All of these version numbers are |
| // unsafe for the user to use as their minimum SDK constraint, because |
| // if they published their package, it wouldn't be usable with the |
| // latest beta release. So just fall back on using a version of |
| // `<stable release>-0`. |
| return Version.parse('$nullSafetyStableReleaseVersion-0'); |
| } |
| } |
| } |
| |
| /// Get the "root" of all [included] paths. See [includedRoot] for its |
| /// definition. |
| static String _getIncludedRoot( |
| List<String> included, ResourceProvider provider) { |
| var context = provider.pathContext; |
| // This step looks like it may be expensive (`getResource`, splitting up |
| // all of the paths, comparing parts, joining one path back together). In |
| // practice, this should be cheap because typically only one path is given |
| // to dartfix. |
| var rootParts = included |
| .map((p) => context.normalize(context.absolute(p))) |
| .map((p) => provider.getResource(p) is File ? context.dirname(p) : p) |
| .map((p) => context.split(p)) |
| .reduce((value, parts) { |
| var shorterPath = value.length < parts.length ? value : parts; |
| var length = shorterPath.length; |
| for (var i = 0; i < length; i++) { |
| if (value[i] != parts[i]) { |
| // [value] and [parts] are the same, only up to part [i]. |
| return value.sublist(0, i); |
| } |
| } |
| // [value] and [parts] are the same up to the full length of the shorter |
| // of the two, so just return that. |
| return shorterPath; |
| }); |
| return context.joinAll(rootParts); |
| } |
| } |
| |
| class NullabilityMigrationAdapter implements NullabilityMigrationListener { |
| final DartFixListener listener; |
| |
| NullabilityMigrationAdapter(this.listener); |
| |
| @override |
| void addEdit(Source source, SourceEdit edit) { |
| listener.addEditWithoutSuggestion(source, edit); |
| } |
| |
| @override |
| void addSuggestion(String descriptions, Location location) { |
| listener.addSuggestion(descriptions, location); |
| } |
| |
| @override |
| void reportException( |
| Source? source, AstNode? node, Object exception, StackTrace stackTrace) { |
| listener.client.onException(''' |
| $exception at offset ${node!.offset} in $source ($node) |
| |
| $stackTrace'''); |
| } |
| } |
| |
| class _YamlFile { |
| final String path; |
| final String textContent; |
| |
| final YamlNode content; |
| |
| _YamlFile._(this.path, this.textContent, this.content); |
| |
| String? _getName() { |
| YamlNode? packageNameNode; |
| |
| if (content is YamlMap) { |
| packageNameNode = (content as YamlMap).nodes['name']; |
| } else { |
| return null; |
| } |
| |
| if (packageNameNode is YamlScalar && packageNameNode.value is String) { |
| return packageNameNode.value as String?; |
| } else { |
| return null; |
| } |
| } |
| |
| /// Inserts [content] into this file, immediately after [parentSpan]. |
| void _insertAfterParent( |
| SourceSpan parentSpan, String content, DartFixListener listener) { |
| var line = parentSpan.end.line; |
| var offset = parentSpan.end.offset; |
| // Walk [offset] and [line] back to the first non-whitespace character |
| // before [offset]. |
| while (offset > 0) { |
| var ch = textContent.codeUnitAt(offset - 1); |
| if (ch == $space || ch == $cr) { |
| --offset; |
| } else if (ch == $lf) { |
| --offset; |
| --line; |
| } else { |
| break; |
| } |
| } |
| var edit = SourceEdit(offset, 0, content); |
| listener.addSourceFileEdit( |
| 'enable Null Safety language feature', |
| Location(path, offset, content.length, line, 0, |
| endLine: 0, endColumn: 0), |
| SourceFileEdit(path, 0, edits: [edit])); |
| } |
| |
| void _replaceSpan(SourceSpan span, String content, DartFixListener listener) { |
| var line = span.start.line; |
| var offset = span.start.offset; |
| var edit = SourceEdit(offset, span.length, content); |
| listener.addSourceFileEdit( |
| 'enable Null Safety language feature', |
| Location(path, offset, content.length, line, 0, |
| endLine: 0, endColumn: 0), |
| SourceFileEdit(path, 0, edits: [edit])); |
| } |
| |
| static _YamlFile _parseFrom(File file) { |
| var textContent = file.readAsStringSync(); |
| var content = loadYaml(textContent); |
| if (content is YamlNode) { |
| return _YamlFile._(file.path, textContent, content); |
| } else { |
| throw FormatException('pubspec.yaml is not a YAML map.'); |
| } |
| } |
| } |