blob: 08f748a3a41abe54ea5f26d860844db3077458f0 [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:convert';
import 'dart:io' as io;
import 'package:analysis_server/src/utilities/profiling.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/src/dart/analysis/experiments.dart';
import 'package:cli_util/cli_logging.dart';
import 'package:dartdev/src/lsp_analysis_server.dart';
import 'package:language_server_protocol/protocol_generated.dart';
import 'package:language_server_protocol/protocol_special.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import '../core.dart';
import '../experiments.dart';
import '../progress.dart';
import '../sdk.dart';
import '../utils.dart';
class AnalyzeCommand extends DartdevCommand {
static const String cmdName = 'analyze';
/// The maximum length of any of the existing severity labels.
static const int _severityWidth = 7;
/// The number of spaces needed to indent follow-on lines (the body) under the
/// message. The width left for the severity label plus the separator width.
static const int _bodyIndentWidth = _severityWidth + 3;
static final String _bodyIndent = ' ' * _bodyIndentWidth;
static final int _pipeCodeUnit = '|'.codeUnitAt(0);
static final int _slashCodeUnit = '\\'.codeUnitAt(0);
static final int _newline = '\n'.codeUnitAt(0);
static final int _return = '\r'.codeUnitAt(0);
/// A cache of [LineInfo]s by absolute file path.
///
/// [LineInfo]s are used to map from the LSP line:col values to 'offset' which
/// is included in JSON and machine output formats.
final Map<String, LineInfo?> _lineInfoCache = {};
AnalyzeCommand({bool verbose = false})
: super(cmdName, 'Analyze Dart code in a directory.', verbose) {
argParser
..addFlag(
'fatal-infos',
help: 'Treat info level issues as fatal.',
negatable: false,
)
..addFlag(
'fatal-warnings',
help: 'Treat warning level issues as fatal.',
defaultsTo: true,
)
// Options hidden by default.
..addOption(
'cache',
valueHelp: 'path',
help: 'Override the location of the analysis cache.',
hide: !verbose,
)
..addFlag(
'memory',
help:
'Attempt to print memory usage before exiting. '
'Will only print if format is json.',
hide: !verbose,
)
..addOption(
'format',
valueHelp: 'value',
help: 'Specifies the format to display errors.',
allowed: ['default', 'json', 'machine'],
allowedHelp: {
'default':
'The default output format. This format is intended to be user '
'consumable.\nThe format is not specified and can change '
'between releases.',
'json': 'A machine readable output in a JSON format.',
'machine':
'A machine readable output. The format is:\n\n'
'SEVERITY|TYPE|ERROR_CODE|FILE_PATH|LINE|COLUMN|LENGTH|ERROR_MESSAGE\n\n'
'Note that the pipe character is escaped with backslashes for '
'the file path and error message fields.',
},
hide: !verbose,
)
..addOption(
'packages',
valueHelp: 'path',
help:
'The path to the package resolution configuration file, which '
'supplies a mapping of package names\ninto paths.',
hide: !verbose,
)
..addOption(
'sdk-path',
valueHelp: 'path',
help: 'The path to the Dart SDK.',
hide: !verbose,
)
..addFlag(
useAotSnapshotFlag,
help: 'Use the AOT analysis server snapshot',
defaultsTo: true,
hide: true,
)
..addFlag(
'plugins',
help: 'Use analyzer plugins',
defaultsTo: true,
hide: true,
)
..addExperimentalFlags();
}
@override
CommandCategory get commandCategory => CommandCategory.sourceCode;
@override
String get invocation => '${super.invocation} [<directory>]';
/// Returns the [LineInfo] for the current version of the file on disk.
///
/// [LineInfo]s are cached for the life of this class to speed up multiple
/// accesses for the same file (for example when there are multiple
/// diagnostics or the same file is referenced in another files context
/// messages).
///
/// Returns `null` if no [LineInfo] is available (for example because the file
/// does not exist). It is assumed, but not guaranteed, that the file content
/// is the same on disk as it was when diagnostics were computed.
@visibleForTesting
LineInfo? getLineInfo(String filePath) {
return _lineInfoCache.putIfAbsent(filePath, () {
try {
var content = io.File(filePath).readAsStringSync();
return LineInfo.fromContent(content);
} catch (_) {
return null;
}
});
}
/// Returns the offset for [pos].
///
/// Returns 0 if the values cannot be computed (for example because the
/// file is no longer available).
///
/// This is used as a fallback if the offset/lengths are not included in the
/// additional diagnostic data, and for context messages which don't have
/// additional data.
@visibleForTesting
int getOffset(String filePath, Position pos) {
var lineInfo = getLineInfo(filePath);
if (lineInfo == null) {
// We can't get a LineInfo so we can't compute this. Rather than crash
// because the line/col are still visible and may be useful.
return 0;
}
return lineInfo.getOffsetOfLine(pos.line) + pos.character;
}
@override
Future<int> run() async {
final args = argResults!;
final globalArgs = globalResults!;
final suppressAnalytics =
!globalArgs.flag('analytics') || globalArgs.flag('suppress-analytics');
// Find targets from the 'rest' params.
final List<io.FileSystemEntity> targets = [];
if (args.rest.isEmpty) {
targets.add(io.Directory.current);
} else {
for (String targetPath in args.rest) {
if (io.Directory(targetPath).existsSync()) {
targets.add(io.Directory(targetPath));
} else if (io.File(targetPath).existsSync()) {
targets.add(io.File(targetPath));
} else {
usageException("Directory or file doesn't exist: $targetPath");
}
}
}
final errorsByFile = <String, List<Diagnostic>>{};
final machineFormat = args.option('format') == 'machine';
final jsonFormat = args.option('format') == 'json';
final printMemory = args.flag('memory') && jsonFormat;
final usePlugins = args.flag('plugins');
io.Directory sdkPath;
final useAotSnapshot = args.flag(useAotSnapshotFlag);
if (args.wasParsed('sdk-path')) {
sdkPath = io.Directory(args.option('sdk-path')!);
if (!sdkPath.existsSync()) {
usageException('Invalid Dart SDK path: ${sdkPath.path}');
}
final snapshotName = useAotSnapshot
? 'analysis_server_aot.dart.snapshot'
: 'analysis_server.dart.snapshot';
final snapshotPath = path.join(
sdkPath.path,
'bin',
'snapshots',
snapshotName,
);
if (!io.File(snapshotPath).existsSync()) {
usageException(
"Invalid Dart SDK path has no '$snapshotName' file: "
'${sdkPath.path}',
);
}
} else {
sdkPath = io.Directory(sdk.sdkPath);
}
final experimentNames = {
for (var experiment in args.enabledExperiments)
if (experiment.startsWith('no-'))
experiment.substring(3)
else
experiment,
};
final unknownExperiments = experimentNames.difference(
ExperimentStatus.knownFeatures.keys.toSet(),
);
if (unknownExperiments.isNotEmpty) {
final unknownExperimentsText = unknownExperiments
.map((e) => "'$e'")
.join(', ');
usageException('Unknown experiment(s): $unknownExperimentsText');
}
final targetsNames = targets
.map((entity) => path.basename(entity.path))
.join(', ');
final server = LspAnalysisServer(
_packagesFile(),
sdkPath,
targets,
cacheDirectoryPath: args.option('cache'),
commandName: 'analyze',
argResults: args,
usePlugins: usePlugins,
enabledExperiments: args.enabledExperiments,
suppressAnalytics: suppressAnalytics,
useAotSnapshot: useAotSnapshot,
);
UsageInfo? usageInfo;
await progress(
'Analyzing $targetsNames',
() async {
server.onErrors.listen((
params,
) {
// Replace any previous results for this file.
errorsByFile[params.uri.toFilePath()] = params.diagnostics;
});
int pid = await server.start();
bool analysisFinished = false;
server.onExit.then((int exitCode) {
if (!analysisFinished) {
io.exitCode = exitCode;
}
});
server.onCrash.then((_) {
log.stderr('The analysis server shut down unexpectedly.');
log.stdout('Please report this at dartbug.com.');
io.exit(_Result.crash.exitCode);
});
// Wait for all analysis to complete.
await server.workspaceAnalysisComplete();
analysisFinished = true;
if (printMemory) {
usageInfo = await ProcessProfiler.getProfilerForPlatform()
?.getProcessUsage(pid);
}
await server.shutdown();
},
output: machineFormat || jsonFormat
? ProgressOutput.none
: ProgressOutput.stdout,
);
/// Errors in analysis_options.yaml and pubspec.yaml will be reported first
/// and a note that they might be the cause of other errors.
final priorityErrors = <DiagnosticWithPath>[];
final nonPriorityErrors = <DiagnosticWithPath>[];
for (final MapEntry(key: filePath, value: fileErrors)
in errorsByFile.entries) {
var isPriorityFile = const {
'analysis_options.yaml',
'pubspec.yaml',
}.contains(path.basename(filePath));
for (var error in fileErrors) {
if (isPriorityFile && error.severity == DiagnosticSeverity.Error) {
priorityErrors.add(DiagnosticWithPath(filePath, error));
} else {
nonPriorityErrors.add(DiagnosticWithPath(filePath, error));
}
}
}
if (priorityErrors.isEmpty && nonPriorityErrors.isEmpty) {
if (jsonFormat) {
emitJsonFormat(log, [], usageInfo);
} else if (!machineFormat && !server.serverErrorReceived) {
log.stdout('No issues found!');
}
return server.serverErrorReceived
? _Result.crash.exitCode
: _Result.success.exitCode;
}
priorityErrors.sort();
nonPriorityErrors.sort();
if (machineFormat) {
emitMachineFormat(log, [...priorityErrors, ...nonPriorityErrors]..sort());
} else if (jsonFormat) {
emitJsonFormat(
log,
[...priorityErrors, ...nonPriorityErrors]..sort(),
usageInfo,
);
} else {
var relativeTo = targets.length == 1 ? targets.single : null;
/// Helper to emit a set of errors.
void emit(List<DiagnosticWithPath> errors) {
emitDefaultFormat(
log,
errors,
relativeToDir: relativeTo is io.File
? relativeTo.parent
: relativeTo as io.Directory?,
verbose: verbose,
);
}
if (priorityErrors.isNotEmpty) {
log.stdout('');
log.stdout(
"Errors were found in 'pubspec.yaml' and/or "
"'analysis_options.yaml' which might result in either invalid "
'diagnostics being produced or valid diagnostics being missed.',
);
emit(priorityErrors);
if (nonPriorityErrors.isNotEmpty) {
log.stdout('Errors in remaining files.');
}
}
if (nonPriorityErrors.isNotEmpty) {
emit(nonPriorityErrors);
}
final errorCount = priorityErrors.length + nonPriorityErrors.length;
log.stdout('$errorCount ${pluralize('issue', errorCount)} found.');
}
bool hasErrors = false;
bool hasWarnings = false;
bool hasInfos = false;
for (final DiagnosticWithPath(filePath: _, diagnostic: error) in [
...priorityErrors,
...nonPriorityErrors,
]) {
hasErrors |= error.severity == DiagnosticSeverity.Error;
hasWarnings |= error.severity == DiagnosticSeverity.Warning;
hasInfos |= error.severity == DiagnosticSeverity.Information;
}
// Return an error code in the range [0-3] dependent on the severity of
// the issue(s) found.
if (hasErrors) {
return _Result.errors.exitCode;
}
bool fatalWarnings = args.flag('fatal-warnings');
bool fatalInfos = args.flag('fatal-infos');
if (fatalWarnings && hasWarnings) {
return _Result.warnings.exitCode;
} else if (fatalInfos && hasInfos) {
return _Result.infos.exitCode;
} else {
return _Result.success.exitCode;
}
}
io.File? _packagesFile() {
var path = argResults!.option('packages');
if (path != null) {
var file = io.File(path);
if (!file.existsSync()) {
usageException("The file doesn't exist: $path");
}
return file.absolute;
} else {
return null;
}
}
@visibleForTesting
void emitDefaultFormat(
Logger log,
List<DiagnosticWithPath> errors, {
io.Directory? relativeToDir,
bool verbose = false,
}) {
final ansi = log.ansi;
final bullet = ansi.bullet;
log.stdout('');
final wrapWidth = dartdevUsageLineLength == null
? null
: (dartdevUsageLineLength! - _bodyIndentWidth);
for (final DiagnosticWithPath(filePath: absolutePath, diagnostic: error)
in errors) {
var data = _readAdditionalData(error.data);
var severity = (error.severity?.displayName?.toLowerCase() ?? '').padLeft(
_severityWidth,
);
if (error.severity == DiagnosticSeverity.Error) {
severity = ansi.error(severity);
}
var relativePath = _relativePath(absolutePath, relativeToDir);
var codeRef = error.code;
// If we're in verbose mode, write any error urls instead of error codes.
var url = error.codeDescription?.href.toString();
if (url != null && verbose) {
codeRef = url;
}
// Emit "file:line:col * Error message. Correction (code)."
var message = ansi.emphasized(error.message.asString);
if (data.correctionMessage != null) {
message += ' ${data.correctionMessage}';
}
var location = '$relativePath:${formatPosition(error.range.start)}';
var output =
'$location $bullet '
'$message $bullet '
'${ansi.green}$codeRef${ansi.none}';
output = wrapText(output, width: wrapWidth);
log.stdout(
'$severity $bullet '
'${output.replaceAll('\n', '\n$_bodyIndent')}',
);
// Add any context messages as bullet list items.
for (var message
in error.relatedInformation ?? <DiagnosticRelatedInformation>[]) {
var absolutePath = message.location.uri.toFilePath();
var contextPath = _relativePath(absolutePath, relativeToDir);
var messageSentenceFragment = trimEnd(message.message, '.');
log.stdout(
'$_bodyIndent'
' - $messageSentenceFragment at '
'$contextPath:${formatPosition(message.location.range.start)}.',
);
}
}
log.stdout('');
}
@visibleForTesting
void emitJsonFormat(
Logger log,
List<DiagnosticWithPath> errors,
UsageInfo? usageInfo,
) {
Map<String, dynamic> locationJson(
String filePath,
Map<String, dynamic> range,
) => {'file': filePath, 'range': range};
Map<String, dynamic> positionJson(int? offset, int? line, int? column) => {
'offset': offset,
'line': line,
'column': column,
};
Map<String, dynamic> rangeJson(
Map<String, dynamic> start,
Map<String, dynamic> end,
) => {'start': start, 'end': end};
var diagnostics = <Map<String, dynamic>>[];
for (final DiagnosticWithPath(:filePath, diagnostic: error) in errors) {
var contextMessages = [];
for (DiagnosticRelatedInformation contextMessage
in error.relatedInformation ?? []) {
var contextFilePath = contextMessage.location.uri.toString();
var range = contextMessage.location.range;
var start = range.start;
var end = range.end;
// We don't have additional data for context messages, so we'll have
// to compute them from the current content.
var startOffset = getOffset(contextFilePath, start);
var endOffset = getOffset(contextFilePath, end);
contextMessages.add({
'location': locationJson(
contextMessage.location.uri.toFilePath(),
rangeJson(
positionJson(
startOffset,
lspToUser(start.line),
lspToUser(start.character),
),
positionJson(
endOffset,
lspToUser(end.line),
lspToUser(end.character),
),
),
),
'message': contextMessage.message,
});
}
var data = _readAdditionalData(error.data);
var range = error.range;
var start = range.start;
var end = range.end;
var startOffset = data.offset ?? getOffset(filePath, start);
var length = data.length ?? getOffset(filePath, end) - startOffset;
var endOffset = startOffset + length;
var url = error.codeDescription?.href.toString();
diagnostics.add({
'code': error.code,
'severity': error.severity?.displayName,
'type': data.type,
'location': locationJson(
filePath,
rangeJson(
positionJson(
startOffset,
lspToUser(start.line),
lspToUser(start.character),
),
positionJson(
endOffset,
lspToUser(end.line),
lspToUser(end.character),
),
),
),
'problemMessage': error.message.asString,
'correctionMessage': ?data.correctionMessage,
if (contextMessages.isNotEmpty) 'contextMessages': contextMessages,
'documentation': ?url,
});
}
log.stdout(
json.encode({
'version': 1,
'diagnostics': diagnostics,
if (usageInfo != null) 'memory': usageInfo.memoryKB,
}),
);
}
@visibleForTesting
void emitMachineFormat(Logger log, List<DiagnosticWithPath> errors) {
for (final DiagnosticWithPath(:filePath, diagnostic: error) in errors) {
var data = _readAdditionalData(error.data);
var start = error.range.start;
var end = error.range.end;
var startOffset = getOffset(filePath, start);
var endOffset = getOffset(filePath, end);
var length = endOffset - startOffset;
log.stdout(
[
error.severity?.displayName?.toUpperCase() ?? '',
data.type ?? '',
error.code?.toUpperCase(),
_escapeForMachineMode(filePath),
lspToUser(error.range.start.line).toString(),
lspToUser(error.range.start.character).toString(),
length,
_escapeForMachineMode(error.message.asString),
].join('|'),
);
}
}
/// Reads the additional data from the Diagnostic.data field.
({int? offset, int? length, String? type, String? correctionMessage})
_readAdditionalData(Object? data) {
var map = data is Map ? data : const {};
T? getIfType<T>(String name) => map[name] is T ? map[name] : null;
var getInt = getIfType<int>;
var getString = getIfType<String>;
return (
offset: getInt('offset'),
length: getInt('length'),
type: getString('type'),
correctionMessage: getString('correctionMessage'),
);
}
static String _escapeForMachineMode(String input) {
var result = StringBuffer();
for (var c in input.codeUnits) {
if (c == _newline) {
result.write(r'\n');
} else if (c == _return) {
result.write(r'\r');
} else {
if (c == _slashCodeUnit || c == _pipeCodeUnit) {
result.write('\\');
}
result.writeCharCode(c);
}
}
return result.toString();
}
/// Return a relative path if it is a shorter reference than the given dir.
static String _relativePath(String givenPath, io.Directory? fromDir) {
String? fromPath = fromDir?.absolute.resolveSymbolicLinksSync();
String relative = path.relative(givenPath, from: fromPath);
return relative.length <= givenPath.length ? relative : givenPath;
}
/// Formats an LSP position for output in the form `line:col`, accounting for
/// LSP being 0-based but server (and users) being 1-based.
static String formatPosition(Position pos) {
return '${lspToUser(pos.line)}:${lspToUser(pos.character)}';
}
/// Converts a line or column number from 0-based (LSP) to 1-based.
static int lspToUser(int i) {
return i + 1;
}
}
/// A [Diagnostic] aling with the [filePath] it belongs to.
///
/// Unlike legacy diagnostics, LSP diagnostics do not include a path/URI because
/// they are grouped together under a [PublishDiagnosticsParams]. Because we
/// sort by severity, we can't use that class for grouping them.
class DiagnosticWithPath implements Comparable<DiagnosticWithPath> {
final String filePath;
final Diagnostic diagnostic;
DiagnosticWithPath(this.filePath, this.diagnostic);
@override
int compareTo(DiagnosticWithPath other) {
// Sort in order of severity, file path, error location, and message.
if (diagnostic.severity != other.diagnostic.severity) {
return _severityPriority(diagnostic) -
_severityPriority(other.diagnostic);
}
if (filePath != other.filePath) {
return filePath.compareTo(other.filePath);
}
if (diagnostic.range.start.line != other.diagnostic.range.start.line) {
return diagnostic.range.start.line - other.diagnostic.range.start.line;
}
if (diagnostic.range.start.character !=
other.diagnostic.range.start.character) {
return diagnostic.range.start.character -
other.diagnostic.range.start.character;
}
return diagnostic.message.asString.compareTo(
other.diagnostic.message.asString,
);
}
static int _severityPriority(Diagnostic diagnostic) {
return switch (diagnostic.severity) {
DiagnosticSeverity.Error => 0,
DiagnosticSeverity.Warning => 1,
DiagnosticSeverity.Hint => 2,
DiagnosticSeverity.Information => 3,
_ => 4,
};
}
}
/// The possible results of analysis and their exit codes.
enum _Result {
/// Analysis completed and there are no diagnostics.
success(0),
/// Analysis completed and there are INFO diagnostics.
infos(1),
/// Analysis completed and there are warning diagnostics.
warnings(2),
/// Analysis completed and there are error diagnostics.
errors(3),
/// The analysis server failed in a way that may make the results invalid.
crash(4);
final int exitCode;
const _Result(this.exitCode);
}
extension on Either2<MarkupContent, String> {
/// Returns the String contents regardless of whether it was LSP
/// MarkupContent or a bare String.
String get asString => map((markup) => markup.value, (string) => string);
}
extension on DiagnosticSeverity {
String? get displayName {
return switch (this) {
// These names match the original values from the server protocol.
DiagnosticSeverity.Error => 'ERROR',
DiagnosticSeverity.Warning => 'WARNING',
DiagnosticSeverity.Information => 'INFO',
DiagnosticSeverity.Hint => 'HINT',
_ => null, // should never happen, but types allow.
};
}
}