blob: 5748c5b00bd5a025aa8fab46e884c562aacb43d3 [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';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:charcode/charcode.dart';
import 'package:cli_util/cli_logging.dart';
import 'package:meta/meta.dart';
import 'package:nnbd_migration/src/edit_plan.dart';
import 'package:nnbd_migration/src/front_end/migration_info.dart';
import 'package:nnbd_migration/src/front_end/migration_state.dart';
import 'package:nnbd_migration/src/front_end/path_mapper.dart';
import 'package:nnbd_migration/src/front_end/web/navigation_tree.dart';
import 'package:nnbd_migration/src/hint_action.dart';
import 'package:nnbd_migration/src/preview/dart_file_page.dart';
import 'package:nnbd_migration/src/preview/dart_logo_page.dart';
import 'package:nnbd_migration/src/preview/exception_page.dart';
import 'package:nnbd_migration/src/preview/highlight_css_page.dart';
import 'package:nnbd_migration/src/preview/highlight_js_page.dart';
import 'package:nnbd_migration/src/preview/http_preview_server.dart';
import 'package:nnbd_migration/src/preview/index_file_page.dart';
import 'package:nnbd_migration/src/preview/material_icons_page.dart';
import 'package:nnbd_migration/src/preview/navigation_tree_page.dart';
import 'package:nnbd_migration/src/preview/not_found_page.dart';
import 'package:nnbd_migration/src/preview/pages.dart';
import 'package:nnbd_migration/src/preview/preview_page.dart';
import 'package:nnbd_migration/src/preview/region_page.dart';
import 'package:nnbd_migration/src/preview/roboto_mono_page.dart';
import 'package:nnbd_migration/src/preview/roboto_page.dart';
import 'package:nnbd_migration/src/preview/unauthorized_page.dart';
// The randomly generated auth token used to access the preview site.
String _makeAuthToken() {
final kTokenByteSize = 8;
var bytes = Uint8List(kTokenByteSize);
var random = Random.secure();
for (var i = 0; i < kTokenByteSize; i++) {
bytes[i] = random.nextInt(256);
}
return base64Url.encode(bytes);
}
/// A plan for an incremental migration.
///
/// This plan uses [UnitMigrationStatus]es from [NavigationTreeNode]s to apply
/// different edits to different files:
///
/// * migrating files will be edited according to a [SourceFileEdit],
/// * newly opted out files will be prepended with a Dart Language Version
/// comment specifying "2.9",
/// * already opted out files will remain unchanged.
class IncrementalPlan {
static final _nonWhitespaceChar = RegExp(r'\S');
final MigrationInfo? migrationInfo;
final Map<String?, UnitInfo> unitInfoMap;
final PathMapper? pathMapper;
final List<SourceFileEdit> edits;
final Logger? logger;
/// The set of units which are to be opted out in this migration.
final Set<String?> optOutUnitPaths;
/// Creates a new [IncrementalPlan], extracting all of the paths which are
/// "opting out" from [navigationTree].
factory IncrementalPlan(
MigrationInfo? migrationInfo,
Map<String?, UnitInfo> unitInfoMap,
PathMapper? pathMapper,
List<SourceFileEdit> edits,
Iterable<NavigationTreeNode> navigationTree,
Logger? logger) {
var optOutUnitPaths = <String?>{};
void addUnitsToOptOut(NavigationTreeNode entity) {
if (entity is NavigationTreeDirectoryNode) {
for (var child in entity.subtree!) {
addUnitsToOptOut(child);
}
} else {
if (entity.migrationStatus == UnitMigrationStatus.optingOut) {
optOutUnitPaths.add(entity.path);
}
}
}
for (var entity in navigationTree) {
addUnitsToOptOut(entity);
}
return IncrementalPlan._(
migrationInfo, unitInfoMap, pathMapper, edits, optOutUnitPaths, logger);
}
IncrementalPlan._(this.migrationInfo, this.unitInfoMap, this.pathMapper,
this.edits, this.optOutUnitPaths, this.logger);
/// Applies this migration to disk.
void apply() {
logger!.stdout('Applying migration suggestions to disk...');
var migratedFiles = <String>[];
for (final fileEdit in edits) {
var unit = unitInfoMap[fileEdit.file];
// Decide whether to opt out; default to `false` files not included in
// [edits], like [pubspec.yaml].
var unitIsOptOut = unit != null
? optOutUnitPaths.contains(migrationInfo!.computeName(unit))
: false;
if (!unitIsOptOut) {
final file = pathMapper!.provider.getFile(fileEdit.file);
var code = file.exists ? file.readAsStringSync() : '';
code = SourceEdit.applySequence(code, fileEdit.edits);
file.writeAsStringSync(code);
migratedFiles.add(migrationInfo!.relativePathFromRoot(fileEdit.file));
}
}
// A file which is to be opted out may not be found in [edits], if all types
// were to be made non-nullable, etc. Iterate over [optOutUnitPaths] instead
// of [edits] to opt files out.
var newlyOptedOutFiles = <String?>[];
var keptOptedOutFiles = <String?>[];
for (var optOutUnitPath in optOutUnitPaths) {
var absolutePath = migrationInfo!.absolutePathFromRoot(optOutUnitPath);
var unit = unitInfoMap[absolutePath]!;
if (unit.wasExplicitlyOptedOut) {
// This unit was explicitly opted out of null safety with a Dart
// Language version comment. Leave the unit be.
keptOptedOutFiles.add(optOutUnitPath);
} else {
// This unit was not yet migrated at the start, was not explicitly
// opted out at the start, and is being opted out now. Add a Dart
// Language version comment.
final file = pathMapper!.provider.getFile(absolutePath);
var code = file.exists ? file.readAsStringSync() : '';
file.writeAsStringSync(optCodeOutOfNullSafety(code));
newlyOptedOutFiles.add(optOutUnitPath);
}
}
_logFileStatus(migratedFiles, (text) => 'Migrated $text');
_logFileStatus(
newlyOptedOutFiles,
(text) =>
'Opted $text out of null safety with a new Dart language version '
'comment');
_logFileStatus(
keptOptedOutFiles, (text) => 'Kept $text opted out of null safety');
}
void _logFileStatus(
List<String?> files, String Function(String text) template) {
if (files.isNotEmpty) {
var count = files.length;
if (count <= 20) {
var s = count > 1 ? 's' : '';
var text = '$count file$s';
logger!.stdout('${template(text)}:');
for (var path in files) {
logger!.stdout(' $path');
}
} else {
var text = '$count files';
logger!.stdout('${template(text)}.');
}
}
}
@visibleForTesting
static String optCodeOutOfNullSafety(String code) {
var newline = '\n';
var length = code.length;
if (length == 0) {
return '// @dart=2.9';
}
var index = 0;
// Returns the next line and updates [index].
//
// After this function returns, [index] points to the character after the
// end of the line which was returned.
String getLine() {
var nextIndex = code.indexOf('\n', index);
if (nextIndex < 0) {
// Last line.
var line = code.substring(index);
index = length;
return line;
}
var line = code.substring(index, nextIndex);
index = nextIndex + 1;
return line;
}
// Skip past blank lines.
var line = getLine();
if (code.codeUnitAt(index - 1) == $lf) {
if (index - 2 >= 0 && code.codeUnitAt(index - 2) == $cr) {
// Looks like Windows-style line endings ("\r\n"). Use "\r\n" for all
// inserted line endings.
newline = '\r\n';
}
}
var lineStart = line.indexOf(_nonWhitespaceChar);
while (lineStart < 0) {
line = getLine();
if (index == length) {
// [code] consists _only_ of blank lines.
return '// @dart=2.9$newline$newline$code';
}
lineStart = line.indexOf(_nonWhitespaceChar);
}
// [line] is the first non-blank line.
if (line.length > lineStart + 1 &&
line.codeUnitAt(lineStart) == $slash &&
line.codeUnitAt(lineStart + 1) == $slash) {
// [line] is a comment.
if (index == length) {
// [code] consists _only_ of one comment line.
return '$code$newline$newline// @dart=2.9$newline';
}
var previousLineIndex = index;
String newlinesAfterDlvc;
while (true) {
previousLineIndex = index;
line = getLine();
lineStart = line.indexOf(_nonWhitespaceChar);
if (lineStart < 0) {
// Line of zero-or-more whitespace; end of block comment.
newlinesAfterDlvc = newline;
break;
}
if (line.length <= lineStart + 1) {
// Only one character; not a comment; end of block comment.
newlinesAfterDlvc = '$newline$newline';
break;
}
if (line.codeUnitAt(lineStart) == $slash &&
line.codeUnitAt(lineStart + 1) == $slash) {
// Comment line.
if (index == length) {
// [code] consists _only_ of this block comment.
return '$code$newline$newline// @dart=2.9$newline';
}
continue;
} else {
// Non-blank, non-comment line.
newlinesAfterDlvc = '$newline$newline';
break;
}
}
// [previousLineIndex] points to the start of [line], which is the first
// non-comment line following the first comment.
return '${code.substring(0, previousLineIndex)}$newline'
'// @dart=2.9$newlinesAfterDlvc'
'${code.substring(previousLineIndex)}';
} else {
// [code] does not start with a block comment.
return '// @dart=2.9$newline$newline$code';
}
}
}
/// The site used to serve pages for the preview tool.
class PreviewSite extends Site
implements AbstractGetHandler, AbstractPostHandler {
/// The path of the CSS page used to style the semantic highlighting within a
/// Dart file.
static const highlightCssPath = '/highlight.css';
/// The path of the JS page used to associate highlighting within a Dart file.
static const highlightJsPath = '/highlight.pack.js';
/// The path of the Dart logo displayed in the toolbar.
static const dartLogoPath = '/dart_192.png';
/// The path of the Material icons font.
static const materialIconsPath = '/MaterialIconsRegular.ttf';
/// The path of the Roboto font.
static const robotoFontPath = '/RobotoRegular.ttf';
/// The path of the Roboto Mono font.
static const robotoMonoFontPath = '/RobotoMonoRegular.ttf';
static const navigationTreePath = '/_preview/navigationTree.json';
static const applyHintPath = '/apply-hint';
static const applyMigrationPath = '/apply-migration';
static const rerunMigrationPath = '/rerun-migration';
/// The state of the migration being previewed.
MigrationState? migrationState;
/// A table mapping the paths of files to the information about the
/// compilation units at those paths.
final Map<String?, UnitInfo> unitInfoMap = {};
// A function provided by DartFix to rerun the migration.
final Future<MigrationState?> Function() rerunFunction;
/// Callback function that should be invoked after successfully applying
/// migration.
final void Function() applyHook;
final Logger? logger;
final String serviceAuthToken = _makeAuthToken();
/// Initialize a newly created site to serve a preview of the results of an
/// NNBD migration.
PreviewSite(
this.migrationState, this.rerunFunction, this.applyHook, this.logger)
: super('NNBD Migration Preview') {
reset();
}
/// Return the information about the migration that will be used to serve up
/// pages.
MigrationInfo? get migrationInfo => migrationState!.migrationInfo;
/// Return the path mapper used to map paths from the unit infos to the paths
/// being served.
PathMapper? get pathMapper => migrationState!.pathMapper;
@override
Page createExceptionPage(String message, StackTrace trace) {
// Use createExceptionPageWithPath instead.
throw UnimplementedError();
}
/// Return a page used to display an exception that occurred while attempting
/// to render another page. The [path] is the path to the page that was being
/// rendered when the exception was thrown. The [message] and [stackTrace] are
/// those from the exception.
Page createExceptionPageWithPath(
String path, String message, StackTrace stackTrace) {
return ExceptionPage(this, path, message, stackTrace);
}
/// Return a page used to display an exception that occurred while attempting
/// to render another page. The [path] is the path to the page that was being
/// rendered when the exception was thrown. The [message] and [stackTrace] are
/// those from the exception.
Page createJsonExceptionResponse(
String path, String message, StackTrace stackTrace) {
return ExceptionPage(this, path, message, stackTrace);
}
Page createUnauthorizedPage(String unauthorizedPath) {
return UnauthorizedPage(this, unauthorizedPath.substring(1));
}
@override
Page createUnknownPage(String unknownPath) {
return NotFoundPage(this, unknownPath.substring(1));
}
@override
Future<void> handleGetRequest(HttpRequest request) async {
var uri = request.uri;
var path = uri.path;
var decodedPath = pathMapper!.reverseMap(uri);
try {
if (path == highlightCssPath) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, HighlightCssPage(this));
} else if (path == highlightJsPath) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, HighlightJSPage(this));
} else if (path == navigationTreePath) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, NavigationTreePage(this));
} else if (path == dartLogoPath) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, DartLogoPage(this));
} else if (path == materialIconsPath) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, MaterialIconsPage(this));
} else if (path == robotoFontPath) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, RobotoPage(this));
} else if (path == robotoMonoFontPath) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, RobotoMonoPage(this));
} else if (path == '/' ||
decodedPath == migrationInfo!.includedRoot ||
decodedPath ==
'${migrationInfo!.includedRoot}${pathMapper!.separator}') {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, IndexFilePage(this));
}
var unitInfo = unitInfoMap[decodedPath];
if (unitInfo != null) {
if (uri.queryParameters.containsKey('inline')) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, DartFilePage(this, unitInfo));
} else if (uri.queryParameters.containsKey('region')) {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, RegionPage(this, unitInfo));
} else {
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(request, IndexFilePage(this));
}
}
// Note: `return await` needed due to
// https://github.com/dart-lang/sdk/issues/39204
return await respond(
request, createUnknownPage(path), HttpStatus.notFound);
} catch (exception, stackTrace) {
_respondInternalError(request, path, exception, stackTrace);
}
}
@override
Future<void> handlePostRequest(HttpRequest request) async {
var uri = request.uri;
var path = uri.path;
try {
// All POST requests must be authorized.
if (!_isAuthorized(request)) {
return _respondUnauthorized(request);
}
if (path == applyMigrationPath) {
var navigationTree =
((await requestBodyJson(request))['navigationTree'] as List)
.map((encoded) => NavigationTreeNode.fromJson(encoded));
performApply(navigationTree);
respondOk(request);
return;
} else if (path == rerunMigrationPath) {
await rerunMigration();
if (migrationState!.hasErrors) {
return await respondJson(
request,
{
'success': false,
'errors': migrationState!.analysisResult!.toJson(),
},
HttpStatus.ok);
} else {
respondOk(request);
}
return;
} else if (path == applyHintPath) {
final hintAction = HintAction.fromJson(await requestBodyJson(request));
await performHintAction(hintAction);
respondOk(request);
return;
} else if (uri.queryParameters.containsKey('replacement')) {
await performEdit(uri);
respondOk(request);
return;
}
} catch (exception, stackTrace) {
_respondInternalError(request, path, exception, stackTrace);
}
}
/// Perform the migration.
void performApply(Iterable<NavigationTreeNode> navigationTree) {
if (migrationState!.hasBeenApplied) {
throw StateError(
'It looks like this migration has already been applied. Try'
' restarting the migration tool if this is not the case.');
}
final edits = migrationState!.listener!.sourceChange.edits;
// Perform a full check that no files have changed before touching the disk.
for (final fileEdit in edits) {
final file = pathMapper!.provider.getFile(fileEdit.file);
if (!file.path.endsWith('.dart')) {
continue;
}
var unitInfo = unitInfoMap[file.path];
if (unitInfo == null) {
// No disk content was recorded for this path at the time migration was
// performed. This usually happens because the file is an unreferenced
// part (and therefore it didn't contribute to the migration at all). So
// just skip this file.
continue;
}
var code = file.exists ? file.readAsStringSync() : '';
if (!unitInfo.hadDiskContent(code)) {
throw StateError('Cannot apply migration. Files on disk do not match'
' the expected pre-migration state. Press the "rerun from sources"'
' button and then try again. (Changed file path is ${file.path})');
}
}
// Eagerly mark the migration applied. If this throws, we cannot go back.
migrationState!.markApplied();
IncrementalPlan(migrationInfo, unitInfoMap, pathMapper, edits,
navigationTree, logger)
.apply();
applyHook();
}
/// Perform the edit indicated by the [uri].
Future<void> performEdit(Uri uri) async {
//
// Update the code on disk.
//
var params = uri.queryParameters;
var path = pathMapper!.reverseMap(uri);
var offset = int.parse(params['offset']!);
var end = int.parse(params['end']!);
var replacement = params['replacement']!;
var file = pathMapper!.provider.getFile(path);
var diskContent = file.readAsStringSync();
if (!unitInfoMap[path]!.hadDiskContent(diskContent)) {
throw StateError('Cannot perform edit. This file has been changed since'
' last migration run. Press the "rerun from sources" button and then'
' try again. (Changed file path is ${file.path})');
}
final unitInfo = unitInfoMap[path]!;
final diskMapper = unitInfo.diskChangesOffsetMapper;
final diskOffsetStart = diskMapper.map(offset);
final diskOffsetEnd = diskMapper.map(end);
if (diskOffsetStart == null || diskOffsetEnd == null) {
throw StateError('Cannot perform edit. Relevant code has been deleted by'
' a previous hint action. Rerun the migration and try again.');
}
unitInfo.handleSourceEdit(SourceEdit(offset, end - offset, replacement));
migrationState!.needsRerun = true;
var newContent =
diskContent.replaceRange(diskOffsetStart, diskOffsetEnd, replacement);
file.writeAsStringSync(newContent);
unitInfo.diskContent = newContent;
}
/// Perform the hint edit indicated by the [hintAction].
Future<void> performHintAction(HintAction hintAction) async {
final node = migrationState!.nodeMapper.nodeForId(hintAction.nodeId)!;
final edits = node.hintActions[hintAction.kind];
if (edits == null) {
throw StateError('This edit was not available to perform.');
}
//
// Update the code on disk.
//
var path = node.codeReference!.path;
var file = pathMapper!.provider.getFile(path);
var diskContent = file.readAsStringSync();
if (!unitInfoMap[path]!.hadDiskContent(diskContent)) {
throw StateError('Cannot perform edit. This file has been changed since'
' last migration run. Press the "rerun from sources" button and then'
' try again. (Changed file path is ${file.path})');
}
final unitInfo = unitInfoMap[path]!;
final diskMapper = unitInfo.diskChangesOffsetMapper;
var newContent = diskContent;
migrationState!.needsRerun = true;
for (final entry in edits.entries) {
final offset = entry.key!;
final edits = entry.value;
final diskOffset = diskMapper.map(offset);
if (diskOffset == null) {
throw StateError(
'Cannot perform edit. Relevant code has been deleted by'
' a previous hint action. Rerun the migration and try again.');
}
final unmappedSourceEdit = edits.toSourceEdit(offset);
final diskSourceEdit = edits.toSourceEdit(diskMapper.map(offset)!);
unitInfo.handleSourceEdit(unmappedSourceEdit);
newContent = diskSourceEdit.apply(newContent);
}
file.writeAsStringSync(newContent);
unitInfo.diskContent = newContent;
}
Future<Map<String, Object?>> requestBodyJson(HttpRequest request) async =>
(await request
.map((entry) => entry.map((i) => i.toInt()).toList())
.transform<String>(Utf8Decoder())
.transform(JsonDecoder())
.single) as Map<String, Object?>;
Future<void> rerunMigration() async {
migrationState = await rerunFunction();
if (!migrationState!.hasErrors) {
reset();
}
}
void reset() {
unitInfoMap.clear();
var unitInfos = migrationInfo!.units!;
var provider = pathMapper!.provider;
for (var unit in unitInfos) {
unitInfoMap[unit.path] = unit;
}
for (var unit in migrationInfo!.unitMap.values) {
if (!unitInfos.contains(unit)) {
if (unit.content == null) {
try {
unit.content = provider.getFile(unit.path!).readAsStringSync();
} catch (_) {
// If we can't read the content of the file, then skip it.
continue;
}
}
unitInfoMap[unit.path] = unit;
}
}
}
@override
Future<void> respond(HttpRequest request, Page page,
[int code = HttpStatus.ok]) async {
if (page is PreviewPage && page.requiresAuth) {
if (!_isAuthorized(request)) {
return _respondUnauthorized(request);
}
}
var response = request.response;
response.statusCode = code;
if (page is HighlightCssPage) {
response.headers.contentType =
ContentType('text', 'css', charset: 'utf-8');
} else if (page is HighlightJSPage) {
response.headers.contentType =
ContentType('application', 'javascript', charset: 'utf-8');
} else if (page is DartLogoPage) {
response.headers.contentType = ContentType('image', 'png');
} else if (page is MaterialIconsPage ||
page is RobotoPage ||
page is RobotoMonoPage) {
response.headers.contentType = ContentType('font', 'ttf');
} else {
response.headers.contentType = ContentType.html;
}
response.write(await page.generate(request.uri.queryParameters));
response.close();
}
/// Returns whether [request] is an authorized request.
bool _isAuthorized(HttpRequest request) {
var authToken = request.uri.queryParameters['authToken'];
return authToken == serviceAuthToken;
}
Future<void> _respondInternalError(HttpRequest request, String path,
dynamic exception, StackTrace stackTrace) async {
try {
if (request.headers.contentType!.subType == 'json') {
return await respondJson(
request,
{
'success': false,
'exception': exception.toString(),
'stackTrace': stackTrace.toString(),
},
HttpStatus.internalServerError);
}
await respond(
request,
createExceptionPageWithPath(path, '$exception', stackTrace),
HttpStatus.internalServerError);
} catch (exception, stackTrace) {
var response = request.response;
response.statusCode = HttpStatus.internalServerError;
response.headers.contentType = ContentType.text;
response.write('$exception\n\n$stackTrace');
response.close();
}
}
/// Responds with a 401 Unauthorized response.
Future<void> _respondUnauthorized(HttpRequest request) async {
var page = createUnauthorizedPage(request.uri.path);
var response = request.response;
response
..statusCode = HttpStatus.unauthorized
..headers.contentType = ContentType.html
..write(await page.generate(request.uri.queryParameters))
..close();
}
}