blob: c4beef44ab282020766ea01003ec0148e515bd99 [file]
// Copyright (c) 2020, 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:collection';
import 'dart:io' as io;
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:cli_util/cli_logging.dart' show Progress;
import 'package:collection/collection.dart';
import 'package:dartdev/src/commands/utils/lsp_workspace_edits.dart';
import 'package:dartdev/src/lsp_analysis_server.dart';
import 'package:language_server_protocol/protocol_custom_generated.dart' as lsp;
import 'package:language_server_protocol/protocol_generated.dart' as lsp;
import 'package:path/path.dart' as path;
import '../core.dart';
import '../experiments.dart';
import '../sdk.dart';
import '../utils.dart';
class FixCommand extends DartdevCommand {
static const String cmdName = 'fix';
static const String cmdDescription =
'''Apply automated fixes to Dart source code.
This tool looks for and fixes analysis issues that have associated automated fixes.
To use the tool, run either ['dart fix --dry-run'] for a preview of the proposed changes for a project, or ['dart fix --apply'] to apply the changes.''';
/// The maximum number of times that fixes will be requested from the server.
static const maxPasses = 4;
/// A map from the canonicalized absolute path of a file to the updated
/// content of the file.
final Map<String, String> fileContentCache = LinkedHashMap<String, String>(
equals: (key1, key2) => path.canonicalize(key1) == path.canonicalize(key2),
hashCode: (key) => path.canonicalize(key).hashCode,
);
/// The target (path) specified on the command line.
late String argsTarget;
FixCommand({bool verbose = false}) : super(cmdName, cmdDescription, verbose) {
argParser
..addFlag(
'dry-run',
abbr: 'n',
defaultsTo: false,
negatable: false,
help: 'Preview the proposed changes but make no changes.',
)
..addFlag(
'apply',
defaultsTo: false,
negatable: false,
help: 'Apply the proposed changes.',
)
..addMultiOption(
'code',
help: 'Apply fixes for one (or more) diagnostic codes.',
valueHelp: 'code1,code2,...',
)
..addFlag(
'compare-to-golden',
defaultsTo: false,
negatable: false,
help:
'Compare the result of applying fixes to a golden file for testing.',
hide: !verbose,
)
..addFlag(
useAotSnapshotFlag,
help: 'Use the AOT analysis server snapshot',
defaultsTo: true,
hide: true,
)
..addExperimentalFlags(verbose: verbose);
}
@override
CommandCategory get commandCategory => CommandCategory.sourceCode;
@override
String get description {
if (log.ansi.useAnsi) {
return cmdDescription
.replaceAll('[', log.ansi.bold)
.replaceAll(']', log.ansi.none);
} else {
return cmdDescription.replaceAll('[', '').replaceAll(']', '');
}
}
@override
Future<int> run() async {
final args = argResults!;
final globalArgs = globalResults!;
final suppressAnalytics =
!globalArgs.flag('analytics') || globalArgs.flag('suppress-analytics');
var dryRun = args.flag('dry-run');
var inTestMode = args.flag('compare-to-golden');
var apply = args.flag('apply');
if ((!apply && !dryRun && !inTestMode) ||
(apply && dryRun && !inTestMode)) {
printUsage();
return 0;
}
var codes = args.multiOption('code');
var rest = args.rest;
var target = getTarget(rest);
if (!target.existsSync()) {
var entity = target.isDirectory ? 'Directory' : 'File';
usageException("$entity doesn't exist: ${target.path}");
}
if (inTestMode && !target.isDirectory) {
usageException('Golden comparison requires a directory argument.');
}
argsTarget = rest.isNotEmpty ? rest.first : '';
var fixPath = target.path;
var modeText = dryRun ? ' (dry run)' : '';
final targetName = path.basename(fixPath);
Progress? computeFixesProgress = log.progress(
'Computing fixes in ${log.ansi.emphasized(targetName)}$modeText',
);
var server = LspAnalysisServer(
null,
io.Directory(sdk.sdkPath),
[target],
commandName: 'fix',
argResults: argResults,
// TODO(srawlins): Flip to `true` (or flag value) when plugins can bulk
// fix.
usePlugins: false,
suppressAnalytics: suppressAnalytics,
enabledExperiments: args.enabledExperiments,
useAotSnapshot: args.flag(useAotSnapshotFlag),
);
await server.start();
server.onExit.then((int exitCode) {
if (computeFixesProgress != null && exitCode != 0) {
computeFixesProgress?.cancel();
computeFixesProgress = null;
io.exitCode = exitCode;
}
});
server.onCrash.then((_) {
log.stderr('The analysis server shut down unexpectedly.');
log.stdout('Please report this at dartbug.com.');
io.exit(1);
});
lsp.DartGetWorkspaceFixesResult result;
try {
await server.workspaceAnalysisComplete();
result = await server.getFixes(codes);
} on LspRequestError catch (error) {
log.stdout('Unable to compute fixes: ${error.error.message}');
// todo(pq): consider another code
// (also consider encoding this in the server result)
return 3;
} finally {
await server.shutdown();
}
var edit = result.edit;
var details = result.details;
if (edit != null) {
_applyEdits(edit);
}
if (computeFixesProgress != null) {
computeFixesProgress!.finish(showTiming: true);
computeFixesProgress = null;
}
var dir = target.isDirectory ? target as io.Directory : target.parent;
if (inTestMode) {
var result = _compareFixesInDirectory(dir);
log.stdout('Passed: ${result.passCount}, Failed: ${result.failCount}');
return result.failCount > 0 ? 1 : 0;
} else if (edit == null || !edit.hasEdits) {
log.stdout('Nothing to fix!');
} else {
var fileCount = details.length;
var fixCount = details
.expand((detail) => detail.fixes)
.fold<int>(
0,
(int previousValue, fixes) => previousValue + fixes.occurrences,
);
if (dryRun) {
log.stdout('');
log.stdout(
'$fixCount proposed ${_pluralFix(fixCount)} '
'in $fileCount ${pluralize("file", fileCount)}.',
);
_printDetails(details, dir);
_printApplyFixDetails(details);
} else {
var applyFixesProgress = log.progress('Applying fixes');
_writeFiles();
applyFixesProgress.finish(showTiming: true);
_printDetails(details, dir);
log.stdout(
'$fixCount ${_pluralFix(fixCount)} made in '
'$fileCount ${pluralize("file", fileCount)}.',
);
}
}
return 0;
}
void _applyEdits(lsp.WorkspaceEdit edit) {
String? readFile(String filePath) {
return fileContentCache.putIfAbsent(filePath, () {
var file = io.File(filePath);
return file.existsSync() ? file.readAsStringSync() : '';
});
}
void writeFile(String filePath, String content) {
fileContentCache[filePath] = content;
}
applyWorkspaceEdit(edit, readFile, writeFile);
}
/// Return `true` if any of the fixes fail to create the same content as is
/// found in the golden file.
_TestResult _compareFixesInDirectory(io.Directory directory) {
var result = _TestResult();
//
// Gather the files of interest in this directory and process
// subdirectories.
//
var dartFiles = <io.File>[];
var expectFileMap = <String, io.File>{};
for (var child in directory.listSync()) {
if (child is io.Directory) {
var childResult = _compareFixesInDirectory(child);
result.passCount += childResult.passCount;
result.failCount += childResult.failCount;
} else if (child is io.File) {
var name = child.name;
if (name.endsWith('.dart')) {
dartFiles.add(child);
} else if (name.endsWith('.expect')) {
expectFileMap[child.path] = child;
}
}
}
for (var originalFile in dartFiles) {
var filePath = originalFile.path;
var baseName = path.basename(filePath);
var expectFileName = '$baseName.expect';
var expectFilePath = path.join(path.dirname(filePath), expectFileName);
var expectFile = expectFileMap.remove(expectFilePath);
if (expectFile == null) {
result.failCount++;
log.stdout(
'No corresponding expect file for the Dart file at "$filePath".',
);
continue;
}
try {
var expectedCode = expectFile.readAsStringSync();
var actualIsOriginal = !fileContentCache.containsKey(filePath);
var actualCode = actualIsOriginal
? originalFile.readAsStringSync()
: fileContentCache[filePath]!;
// Use a whitespace insensitive comparison.
if (_compressWhitespace(actualCode) !=
_compressWhitespace(expectedCode)) {
result.failCount++;
// TODO(brianwilkerson) Do a better job of displaying the differences.
// It's very hard to see the diff with large files.
_reportFailure(
filePath,
actualCode,
expectedCode,
actualIsOriginal: actualIsOriginal,
);
} else {
result.passCount++;
}
} on io.FileSystemException {
result.failCount++;
log.stdout('Failed to process "$filePath".');
log.stdout(
' Ensure that the file and its expect file are both readable.',
);
}
}
//
// Report any `.expect` files that have no corresponding `.dart` file.
//
for (var unmatchedExpectPath in expectFileMap.keys) {
result.failCount++;
log.stdout(
'No corresponding Dart file for the expect file at "$unmatchedExpectPath".',
);
}
return result;
}
/// Compress sequences of whitespace characters into a single space.
String _compressWhitespace(String code) =>
code.replaceAll(RegExp(r'\s+'), ' ');
String _pluralFix(int count) => count == 1 ? 'fix' : 'fixes';
void _printApplyFixDetails(List<LspBulkFix> details) {
var codes = <String>{};
for (var fixes in details) {
for (var fix in fixes.fixes) {
codes.add(fix.code);
}
}
log.stdout('To fix an individual diagnostic, run one of:');
for (var code in codes.sorted()) {
log.stdout(' dart fix --apply --code=$code $argsTarget');
}
log.stdout('');
log.stdout('To fix all diagnostics, run:');
log.stdout(' dart fix --apply $argsTarget');
}
void _printDetails(List<LspBulkFix> details, io.Directory workingDir) {
String relative(String absolutePath) {
return path.relative(absolutePath, from: workingDir.path);
}
log.stdout('');
final bullet = log.ansi.bullet;
details.sort(
(first, second) =>
relative(first.uri.path).compareTo(relative(second.uri.path)),
);
for (var detail in details) {
log.stdout(relative(detail.uri.toFilePath()));
final fixes = detail.fixes.toList();
fixes.sort((a, b) => a.code.compareTo(b.code));
for (var fix in fixes) {
log.stdout(
' ${fix.code} $bullet '
'${fix.occurrences} ${_pluralFix(fix.occurrences)}',
);
}
log.stdout('');
}
}
/// Report that the [actualCode] produced by applying fixes to the content of
/// [filePath] did not match the [expectedCode].
void _reportFailure(
String filePath,
String actualCode,
String expectedCode, {
required bool actualIsOriginal,
}) {
log.stdout('Failed when applying fixes to $filePath');
log.stdout('Expected:');
log.stdout(expectedCode);
log.stdout('');
if (actualIsOriginal) {
log.stdout('Actual (original code was unchanged):');
} else {
log.stdout('Actual:');
}
log.stdout(actualCode);
}
/// Write the modified contents of files in the [fileContentCache] to disk.
void _writeFiles() {
for (var entry in fileContentCache.entries) {
var file = io.File(entry.key);
file.writeAsStringSync(entry.value);
}
}
}
/// The result of running tests in a given directory.
class _TestResult {
/// The number of tests that passed.
int passCount = 0;
/// The number of tests that failed.
int failCount = 0;
/// Initialize a newly created result object.
_TestResult();
}
extension on io.FileSystemEntity {
bool get isDirectory => this is io.Directory;
}