| // 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 HtmlEscape, HtmlEscapeMode, LineSplitter; |
| |
| import 'package:meta/meta.dart'; |
| import 'package:nnbd_migration/nnbd_migration.dart'; |
| import 'package:nnbd_migration/src/front_end/migration_info.dart'; |
| import 'package:nnbd_migration/src/front_end/path_mapper.dart'; |
| import 'package:nnbd_migration/src/front_end/web/file_details.dart'; |
| import 'package:path/path.dart' as path; |
| |
| /// Instrumentation display output for a library that was migrated to use |
| /// non-nullable types. |
| class UnitRenderer { |
| /// A converter which only escapes "&", "<", and ">". Safe for use in HTML |
| /// text, between HTML elements. |
| static const HtmlEscape _htmlEscape = |
| HtmlEscape(HtmlEscapeMode(escapeLtGt: true)); |
| |
| /// List of kinds of nullability fixes that should be displayed in the |
| /// "proposed edits" area, in the order in which they should be displayed. |
| @visibleForTesting |
| static const List<NullabilityFixKind> kindPriorityOrder = [ |
| NullabilityFixKind.noValidMigrationForNull, |
| NullabilityFixKind.compoundAssignmentHasBadCombinedType, |
| NullabilityFixKind.compoundAssignmentHasNullableSource, |
| NullabilityFixKind.addThen, |
| NullabilityFixKind.removeDeadCode, |
| NullabilityFixKind.conditionTrueInStrongMode, |
| NullabilityFixKind.conditionFalseInStrongMode, |
| NullabilityFixKind.nullAwareAssignmentUnnecessaryInStrongMode, |
| NullabilityFixKind.nullAwarenessUnnecessaryInStrongMode, |
| NullabilityFixKind.otherCastExpression, |
| NullabilityFixKind.changeMethodName, |
| NullabilityFixKind.checkExpression, |
| NullabilityFixKind.addRequired, |
| NullabilityFixKind.makeTypeNullable, |
| NullabilityFixKind.downcastExpression, |
| NullabilityFixKind.addType, |
| NullabilityFixKind.replaceVar, |
| NullabilityFixKind.removeAs, |
| NullabilityFixKind.addLate, |
| NullabilityFixKind.addLateDueToTestSetup, |
| NullabilityFixKind.removeNullableAnnotation, |
| NullabilityFixKind.addLateDueToHint, |
| NullabilityFixKind.addLateFinalDueToHint, |
| NullabilityFixKind.checkExpressionDueToHint, |
| NullabilityFixKind.makeTypeNullableDueToHint, |
| NullabilityFixKind.addImport, |
| NullabilityFixKind.removeLanguageVersionComment |
| ]; |
| |
| /// Displays information for a compilation unit. |
| final UnitInfo unitInfo; |
| |
| /// Information for a whole migration, so that libraries can reference each |
| /// other. |
| final MigrationInfo? migrationInfo; |
| |
| /// An object used to map the file paths of analyzed files to the file paths |
| /// of the HTML files used to view the content of those files. |
| final PathMapper? pathMapper; |
| |
| /// The auth token for the current site, for use in generating URIs. |
| final String authToken; |
| |
| /// Creates an output object for the given library info. |
| UnitRenderer( |
| this.unitInfo, this.migrationInfo, this.pathMapper, this.authToken); |
| |
| /// Return the path context used to manipulate paths. |
| path.Context get pathContext => migrationInfo!.pathContext; |
| |
| /// Builds a JSON view of the instrumentation information in [unitInfo]. |
| FileDetails render() { |
| return FileDetails( |
| regions: _computeRegionContent(unitInfo), |
| navigationContent: _computeNavigationContent(), |
| sourceCode: unitInfo.content, |
| edits: _computeEditList()); |
| } |
| |
| /// Returns the list of edits, as JSON. |
| Map<String, List<EditListItem>> _computeEditList() { |
| var editListsByKind = <NullabilityFixKind, List<EditListItem>>{}; |
| for (var region in unitInfo.regions) { |
| var kind = region.kind; |
| if (kind != null && region.isCounted) { |
| (editListsByKind[kind] ??= []).add(EditListItem( |
| line: region.lineNumber, |
| explanation: region.explanation, |
| offset: region.offset)); |
| } |
| } |
| // Order the lists and filter out empty categories. |
| var result = <String, List<EditListItem>>{}; |
| for (var kind in kindPriorityOrder) { |
| var edits = editListsByKind[kind]; |
| if (edits != null) { |
| result[_headerForKind(kind, edits.length)] = edits; |
| } |
| } |
| return result; |
| } |
| |
| /// Returns the content of the file with navigation links and anchors added. |
| /// |
| /// The content of the file (not including added links and anchors) will be |
| /// HTML-escaped. |
| String _computeNavigationContent() { |
| var content = unitInfo.content; |
| var mapper = unitInfo.offsetMapper; |
| var openInsertions = <int, String>{}; |
| var closeInsertions = <int, String>{}; |
| // |
| // Compute insertions for navigation targets. |
| // |
| for (var region in unitInfo.targets) { |
| if (region.length > 0) { |
| var openOffset = mapper.map(region.offset); |
| if (openOffset == null) { |
| // Region has been deleted via a hint action. |
| continue; |
| } |
| var openInsertion = openInsertions[openOffset] ?? ''; |
| openInsertion = '<span id="o${region.offset}">$openInsertion'; |
| openInsertions[openOffset] = openInsertion; |
| |
| var closeOffset = openOffset + region.length; |
| var closeInsertion = closeInsertions[closeOffset] ?? ''; |
| closeInsertion = '$closeInsertion</span>'; |
| closeInsertions[closeOffset] = closeInsertion; |
| } |
| } |
| // |
| // Compute insertions for navigation sources, but skip the sources that |
| // point at themselves. |
| // |
| for (var region in unitInfo.sources ?? <NavigationSource>[]) { |
| if (region.length > 0) { |
| var openOffset = mapper.map(region.offset); |
| if (openOffset == null) { |
| // Region has been deleted via a hint action. |
| continue; |
| } |
| var target = region.target; |
| if (target.filePath != unitInfo.path || |
| region.offset != target.offset) { |
| var openInsertion = openInsertions[openOffset] ?? ''; |
| var targetUri = _uriForPath(pathMapper!.map(target.filePath), target); |
| openInsertion = |
| '<a href="$targetUri" class="nav-link">$openInsertion'; |
| openInsertions[openOffset] = openInsertion; |
| |
| var closeOffset = openOffset + region.length; |
| var closeInsertion = closeInsertions[closeOffset] ?? ''; |
| closeInsertion = '$closeInsertion</a>'; |
| closeInsertions[closeOffset] = closeInsertion; |
| } |
| } |
| } |
| // |
| // Apply the insertions that have been computed. |
| // |
| var offsets = <int>[...openInsertions.keys, ...closeInsertions.keys]; |
| offsets.sort(); |
| var navContent2 = StringBuffer(); |
| var previousOffset = 0; |
| for (var offset in offsets) { |
| navContent2.write( |
| _htmlEscape.convert(content!.substring(previousOffset, offset))); |
| navContent2.write(closeInsertions[offset] ?? ''); |
| navContent2.write(openInsertions[offset] ?? ''); |
| previousOffset = offset; |
| } |
| if (previousOffset < content!.length) { |
| navContent2.write(_htmlEscape.convert(content.substring(previousOffset))); |
| } |
| return navContent2.toString(); |
| } |
| |
| /// Returns the content of regions, based on the [unitInfo] for both |
| /// unmodified and modified regions. |
| /// |
| /// The content of the file (not including added links and anchors) will be |
| /// HTML-escaped. |
| String _computeRegionContent(UnitInfo unit) { |
| var content = unitInfo.content!; |
| var rows = <String>[]; |
| var currentTextCell = StringBuffer(); |
| bool isAddedLine = false; |
| var lineNumber = 1; |
| |
| void finishRow(bool isAddedText) { |
| var line = currentTextCell.toString(); |
| if (isAddedLine) { |
| rows.add('<tr><td class="line-no">(new)</td><td>$line</td></tr>'); |
| } else { |
| rows.add('<tr><td class="line-no">$lineNumber</td>' |
| '<td class="line-$lineNumber">$line</td></tr>'); |
| lineNumber++; |
| } |
| currentTextCell = StringBuffer(); |
| isAddedLine = isAddedText; |
| } |
| |
| void writeSplitLines( |
| String lines, { |
| String perLineOpeningTag = '', |
| String perLineClosingTag = '', |
| bool isAddedText = false, |
| }) { |
| var lineIterator = LineSplitter.split(lines).iterator; |
| lineIterator.moveNext(); |
| |
| while (true) { |
| currentTextCell.write(perLineOpeningTag); |
| currentTextCell.write(_htmlEscape.convert(lineIterator.current)); |
| currentTextCell.write(perLineClosingTag); |
| if (lineIterator.moveNext()) { |
| // If we're not on the last element, end this row, and get ready to |
| // start a new row. |
| finishRow(isAddedText); |
| } else { |
| break; |
| } |
| } |
| |
| if (lines.endsWith('\n')) { |
| finishRow(isAddedText); |
| } |
| } |
| |
| /// Returns the CSS class for a region with a given [RegionType]. |
| String classForRegion(RegionType type) { |
| switch (type) { |
| case RegionType.add: |
| return 'added-region'; |
| case RegionType.remove: |
| return 'removed-region'; |
| case RegionType.informative: |
| return 'informative-region'; |
| } |
| } |
| |
| var previousOffset = 0; |
| for (var region in unitInfo.regions) { |
| var offset = region.offset; |
| var length = region.length; |
| if (offset > previousOffset) { |
| // Display a region of unmodified content. |
| writeSplitLines(content.substring(previousOffset, offset)); |
| } |
| previousOffset = offset + length; |
| var shouldBeShown = region.kind != null; |
| var regionClass = classForRegion(region.regionType); |
| var regionSpanTag = shouldBeShown |
| ? '<span class="region $regionClass" ' |
| 'data-offset="$offset" data-line="$lineNumber">' |
| : ''; |
| writeSplitLines(content.substring(offset, offset + length), |
| perLineOpeningTag: regionSpanTag, |
| perLineClosingTag: shouldBeShown ? '</span>' : '', |
| isAddedText: region.regionType == RegionType.add); |
| } |
| if (previousOffset < content.length) { |
| // Last region of unmodified content. |
| writeSplitLines(content.substring(previousOffset)); |
| } |
| finishRow(false); |
| return '<table data-path="${pathMapper!.map(unit.path!)}"><tbody>' |
| '${rows.join()}</tbody></table>'; |
| } |
| |
| String _headerForKind(NullabilityFixKind kind, int count) { |
| var s = count == 1 ? '' : 's'; |
| var es = count == 1 ? '' : 'es'; |
| switch (kind) { |
| case NullabilityFixKind.addThen: |
| return '$count invocation$s of `.then` added'; |
| case NullabilityFixKind.addImport: |
| return '$count import$s added'; |
| case NullabilityFixKind.addLate: |
| return '$count late keyword$s added'; |
| case NullabilityFixKind.addLateDueToHint: |
| return '$count late hint$s converted to late keyword$s'; |
| case NullabilityFixKind.addLateDueToTestSetup: |
| return '$count late keyword$s added, due to assignment in `setUp`'; |
| case NullabilityFixKind.addLateFinalDueToHint: |
| return '$count late final hint$s converted to late and final keywords'; |
| case NullabilityFixKind.addRequired: |
| return '$count required keyword$s added'; |
| case NullabilityFixKind.addType: |
| return '$count type$s added'; |
| case NullabilityFixKind.changeMethodName: |
| return '$count method name$s changed'; |
| case NullabilityFixKind.downcastExpression: |
| return '$count downcast$s added'; |
| case NullabilityFixKind.otherCastExpression: |
| return '$count cast$s (non-downcast) added'; |
| case NullabilityFixKind.checkExpression: |
| return '$count null check$s added'; |
| case NullabilityFixKind.checkExpressionDueToHint: |
| return '$count null check hint$s converted to null check$s'; |
| case NullabilityFixKind.compoundAssignmentHasBadCombinedType: |
| return "$count compound assignment$s couldn't be migrated (bad " |
| 'combined type)'; |
| case NullabilityFixKind.compoundAssignmentHasNullableSource: |
| return "$count compound assignment$s couldn't be migrated (nullable " |
| 'source)'; |
| case NullabilityFixKind.conditionTrueInStrongMode: |
| return '$count condition$s will be true in strong checking mode'; |
| case NullabilityFixKind.conditionFalseInStrongMode: |
| return '$count condition$s will be false in strong checking mode'; |
| case NullabilityFixKind.makeTypeNullable: |
| return '$count type$s made nullable'; |
| case NullabilityFixKind.makeTypeNullableDueToHint: |
| return '$count nullability hint$s converted to ?$s'; |
| case NullabilityFixKind.noValidMigrationForNull: |
| return "$count literal `null`$s couldn't be migrated"; |
| case NullabilityFixKind.nullAwarenessUnnecessaryInStrongMode: |
| return '$count null-aware access$es will be unnecessary in strong ' |
| 'checking mode'; |
| case NullabilityFixKind.nullAwareAssignmentUnnecessaryInStrongMode: |
| return '$count null-aware assignment$s will be unnecessary in strong ' |
| 'checking mode'; |
| case NullabilityFixKind.removeAs: |
| return '$count cast$s now unnecessary'; |
| case NullabilityFixKind.removeDeadCode: |
| return '$count dead code removal$s'; |
| case NullabilityFixKind.removeLanguageVersionComment: |
| return '$count language version comment$s removed'; |
| case NullabilityFixKind.replaceVar: |
| return "$count 'var' declaration$s replaced"; |
| case NullabilityFixKind.typeNotMadeNullable: |
| return '$count type$s not made nullable'; |
| case NullabilityFixKind.typeNotMadeNullableDueToHint: |
| return '$count type$s not made nullable due to hint$s'; |
| case NullabilityFixKind.removeNullableAnnotation: |
| return '$count @nullable annotation$s removed'; |
| } |
| } |
| |
| /// Returns the URL that will navigate to the given [target] in the file at |
| /// the given [relativePath]. |
| String _uriForPath(String path, NavigationTarget target) { |
| var queryParams = { |
| 'offset': target.offset, |
| if (target.line != null) 'line': target.line, |
| 'authToken': authToken, |
| }.entries.map((entry) => '${entry.key}=${entry.value}').join('&'); |
| return '$path?$queryParams'; |
| } |
| } |