blob: 136711e09083bb9aae87ace49fe269b37791ec09 [file] [log] [blame]
// 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: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;
final LineInfo Function(String) _getLineInfo;
/// The HTTP server that serves the preview tool.
HttpPreviewServer _server;
String authToken;
InstrumentationListener instrumentationListener;
NullabilityMigrationAdapter adapter;
NullabilityMigration migration;
Future<MigrationState> Function() rerunFunction;
/// A list of the URLs corresponding to the included roots.
List<String> previewUrls;
NonNullableFix(this.listener, this.resourceProvider, this._getLineInfo,
this.bindAddress, this._logger,
{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);
await state.refresh(_logger);
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, _getLineInfo,
permissive: true, instrumentation: instrumentationListener);
}
void shutdownServer() {
_server?.close();
_server = null;
}
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) {
_server = HttpPreviewServer(
state, rerun, applyHook, bindAddress, preferredPort);
_server.serveHttp();
_allServers.add(_server);
var serverHostname = await _server.boundHostname;
var serverPort = await _server.boundPort;
authToken = await _server.authToken;
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),
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);
if (currentConstraint is VersionRange &&
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 {
// 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
$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),
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),
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.');
}
}
}