blob: 425f99f166b4481fa87c5aa555a311cb821fdd5e [file] [log] [blame]
// Copyright (c) 2014, 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 'package:analyzer/diagnostic/diagnostic.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/instrumentation/instrumentation.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/source/source.dart';
import 'package:analyzer/src/generated/engine.dart';
import 'package:analyzer/src/utilities/extensions/string.dart';
import 'package:test/test.dart';
/// A description of a message that is expected to be reported with an error.
class ExpectedContextMessage {
/// The path of the file with which the message is associated.
final File file;
/// The offset of the beginning of the error's region.
final int offset;
/// The offset of the beginning of the error's region.
final int length;
/// The message text for the error.
final String? text;
/// A list of patterns that should be contained in the message test; empty if
/// the message contents should not be checked.
final List<Pattern> textContains;
ExpectedContextMessage(
this.file,
this.offset,
this.length, {
this.text,
this.textContains = const [],
});
/// Return `true` if the [message] matches this description of what it's
/// expected to be.
bool matches(DiagnosticMessage message) {
if (message.filePath != file.path) {
return false;
}
if (message.offset != offset) {
return false;
}
if (message.length != length) {
return false;
}
var messageText = message.messageText(includeUrl: true);
if (text != null && messageText != text) {
return false;
}
for (var pattern in textContains) {
if (!messageText.contains(pattern)) {
return false;
}
}
return true;
}
}
/// A description of an error that is expected to be reported.
class ExpectedError {
/// An empty array of error descriptors used when no errors are expected.
static List<ExpectedError> NO_ERRORS = <ExpectedError>[];
/// The diagnostic code associated with the error.
final DiagnosticCode code;
// A pattern that should be contained in the error's correction message, or
// `null` if the correction message contents should not be checked.
final Pattern? correctionContains;
/// The offset of the beginning of the error's region.
final int offset;
/// The offset of the beginning of the error's region.
final int length;
/// The message text of the error or `null` if the message should not be checked.
final String? message;
/// A list of patterns that should be contained in the error message; empty if
/// the message contents should not be checked.
final List<Pattern> messageContains;
/// The list of context messages that are expected to be associated with the
/// error.
final List<ExpectedContextMessage> expectedContextMessages;
/// Initialize a newly created error description.
ExpectedError(
this.code,
this.offset,
this.length, {
this.correctionContains,
this.message,
this.messageContains = const [],
this.expectedContextMessages = const <ExpectedContextMessage>[],
});
/// Return `true` if the [error] matches this description of what it's
/// expected to be.
bool matches(Diagnostic error) {
if (error.offset != offset ||
error.length != length ||
error.diagnosticCode != code) {
return false;
}
if (message != null && error.message != message) {
return false;
}
for (var pattern in messageContains) {
if (!error.message.contains(pattern)) {
return false;
}
}
if (correctionContains != null &&
!(error.correctionMessage ?? '').contains(correctionContains!)) {
return false;
}
List<DiagnosticMessage> contextMessages = error.contextMessages.toList();
if (contextMessages.length != expectedContextMessages.length) {
return false;
}
for (int i = 0; i < expectedContextMessages.length; i++) {
if (!expectedContextMessages[i].matches(contextMessages[i])) {
return false;
}
}
return true;
}
}
/// A diagnostic listener that collects all of the diagnostics passed to it for
/// later examination.
class GatheringDiagnosticListener implements DiagnosticListener {
/// A flag indicating whether error ranges are to be compared when comparing
/// expected and actual diagnostics.
final bool checkRanges;
/// A list containing the diagnostics that were collected.
final List<Diagnostic> _diagnostics = [];
/// A table mapping sources to the line information for the source.
final Map<Source, LineInfo> _lineInfoMap = {};
/// Initialize a newly created diagnostic listener to collect diagnostics.
GatheringDiagnosticListener({this.checkRanges = true});
/// Returns the diagnostics that were collected.
List<Diagnostic> get diagnostics => _diagnostics;
/// Whether at least one diagnostic has been gathered.
bool get hasDiagnostics => _diagnostics.isNotEmpty;
/// Adds the given [diagnostics] to this listener.
void addAll(List<Diagnostic> diagnostics) {
for (Diagnostic diagnostic in diagnostics) {
onDiagnostic(diagnostic);
}
}
/// Adds all of the diagnostics recorded by the given [listener] to this
/// listener.
void addAll2(RecordingDiagnosticListener listener) {
addAll(listener.diagnostics);
}
/// Assert that the number of errors that have been gathered matches the
/// number of [expectedErrors] and that they have the expected error codes and
/// locations. The order in which the errors were gathered is ignored.
void assertErrors(List<ExpectedError> expectedErrors) {
//
// Match actual errors to expected errors.
//
List<Diagnostic> unmatchedActual = diagnostics.toList();
List<ExpectedError> unmatchedExpected = expectedErrors.toList();
int actualIndex = 0;
while (actualIndex < unmatchedActual.length) {
bool matchFound = false;
int expectedIndex = 0;
while (expectedIndex < unmatchedExpected.length) {
if (unmatchedExpected[expectedIndex].matches(
unmatchedActual[actualIndex],
)) {
matchFound = true;
unmatchedActual.removeAt(actualIndex);
unmatchedExpected.removeAt(expectedIndex);
break;
}
expectedIndex++;
}
if (!matchFound) {
actualIndex++;
}
}
//
// Write the results.
//
StringBuffer buffer = StringBuffer();
if (unmatchedExpected.isNotEmpty) {
buffer.writeln('Expected but did not find:');
for (ExpectedError expected in unmatchedExpected) {
buffer.write(' ');
buffer.write(expected.code);
buffer.write(' [');
buffer.write(expected.offset);
buffer.write(', ');
buffer.write(expected.length);
if (expected.message != null) {
buffer.write(', message: ');
buffer.write(json.encode(expected.message));
}
if (expected.messageContains.isNotEmpty) {
buffer.write(', messageContains: ');
buffer.write(
json.encode([
for (var pattern in expected.messageContains) pattern.toString(),
]),
);
}
if (expected.correctionContains != null) {
buffer.write(', correctionContains: ');
buffer.write(json.encode(expected.correctionContains.toString()));
}
if (expected.expectedContextMessages.isNotEmpty) {
buffer.write(', contextMessages: [');
for (var i = 0; i < expected.expectedContextMessages.length; i++) {
var contextMessage = expected.expectedContextMessages[i];
if (i > 0) {
buffer.write(', ');
}
buffer.write('message(');
buffer.write(contextMessage.file.path);
buffer.write(', ');
buffer.write(contextMessage.offset);
buffer.write(', ');
buffer.write(contextMessage.length);
if (contextMessage.text != null) {
buffer.write(', text: ');
buffer.write(json.encode(contextMessage.text));
}
if (contextMessage.textContains.isNotEmpty) {
buffer.write(', textContains: ');
buffer.write(
json.encode([
for (var pattern in contextMessage.textContains)
pattern.toString(),
]),
);
}
buffer.write(')');
}
}
buffer.writeln(']');
}
}
if (unmatchedActual.isNotEmpty) {
if (buffer.isNotEmpty) {
buffer.writeln();
}
buffer.writeln('Found but did not expect:');
for (Diagnostic actual in unmatchedActual) {
buffer.write(' ');
buffer.write(actual.diagnosticCode);
buffer.write(' [');
buffer.write(actual.offset);
buffer.write(', ');
buffer.write(actual.length);
buffer.write(', ');
buffer.write(json.encode(actual.message));
if (actual.correctionMessage != null) {
buffer.write(', ');
buffer.write(json.encode(actual.correctionMessage));
}
if (actual.contextMessages.isNotEmpty) {
buffer.write(', contextMessages: [');
for (var i = 0; i < actual.contextMessages.length; i++) {
var message = actual.contextMessages[i];
if (i > 0) {
buffer.write(', ');
}
buffer.write('message(');
// Special case for `testFile`, used very often.
switch (message.filePath) {
case '/home/test/lib/test.dart':
buffer.write('testFile');
case var filePath:
buffer.write("'$filePath'");
}
buffer.write(', ');
buffer.write(message.offset);
buffer.write(', ');
buffer.write(message.length);
buffer.write(', ');
buffer.write(json.encode(message.messageText(includeUrl: false)));
buffer.write(')');
}
}
buffer.writeln(']');
}
}
if (buffer.isNotEmpty) {
diagnostics.sort(
(first, second) => first.offset.compareTo(second.offset),
);
buffer.writeln();
if (diagnostics.isEmpty) {
buffer.writeln('To accept the current state, expect no errors.');
} else {
buffer.writeln('To accept the current state, expect:');
for (Diagnostic actual in diagnostics) {
List<DiagnosticMessage> contextMessages = actual.contextMessages;
buffer.write(' error(');
buffer.write(actual.diagnosticCode.constantName);
buffer.write(', ');
buffer.write(actual.offset);
buffer.write(', ');
buffer.write(actual.length);
if (contextMessages.isNotEmpty) {
buffer.write(', contextMessages: [');
for (int i = 0; i < contextMessages.length; i++) {
DiagnosticMessage message = contextMessages[i];
if (i > 0) {
buffer.write(', ');
}
buffer.write('message(');
// Special case for `testFile`, used very often.
switch (message.filePath) {
case '/home/test/lib/test.dart':
buffer.write('testFile');
case var filePath:
buffer.write("'$filePath'");
}
buffer.write(', ');
buffer.write(message.offset);
buffer.write(', ');
buffer.write(message.length);
buffer.write(')');
}
buffer.write(']');
}
buffer.writeln('),');
}
}
fail(buffer.toString());
}
}
/// Asserts that the number of diagnostics that have been gathered matches the
/// number of [expectedCodes] and that they have the expected diagnostic
/// codes.
///
/// The order in which the diagnostics were gathered is ignored.
void assertErrorsWithCodes([
List<DiagnosticCode> expectedCodes = const <DiagnosticCode>[],
]) {
StringBuffer buffer = StringBuffer();
// Compute the expected number of each type of diagnostic.
Map<DiagnosticCode, int> expectedCounts = <DiagnosticCode, int>{};
for (DiagnosticCode code in expectedCodes) {
var count = expectedCounts[code];
if (count == null) {
count = 1;
} else {
count = count + 1;
}
expectedCounts[code] = count;
}
//
// Compute the actual number of each type of error.
//
Map<DiagnosticCode, List<Diagnostic>> diagnosticsByCode =
<DiagnosticCode, List<Diagnostic>>{};
for (Diagnostic diagnostic in _diagnostics) {
diagnosticsByCode
.putIfAbsent(diagnostic.diagnosticCode, () => <Diagnostic>[])
.add(diagnostic);
}
//
// Compare the expected and actual number of each type of error.
//
expectedCounts.forEach((DiagnosticCode code, int expectedCount) {
int actualCount;
var list = diagnosticsByCode.remove(code);
if (list == null) {
actualCount = 0;
} else {
actualCount = list.length;
}
if (actualCount != expectedCount) {
if (buffer.length == 0) {
buffer.write("Expected ");
} else {
buffer.write("; ");
}
buffer.write(expectedCount);
buffer.write(" errors of type ");
buffer.write(code.uniqueName);
buffer.write(", found ");
buffer.write(actualCount);
}
});
//
// Check that there are no more errors in the actual-errors map,
// otherwise record message.
//
diagnosticsByCode.forEach((
DiagnosticCode code,
List<Diagnostic> actualDiagnostics,
) {
int actualCount = actualDiagnostics.length;
if (buffer.length == 0) {
buffer.write("Expected ");
} else {
buffer.write("; ");
}
buffer.write("0 errors of type ");
buffer.write(code.uniqueName);
buffer.write(", found ");
buffer.write(actualCount);
buffer.write(" (");
for (int i = 0; i < actualDiagnostics.length; i++) {
Diagnostic diagnostic = actualDiagnostics[i];
if (i > 0) {
buffer.write(", ");
}
buffer.write(diagnostic.offset);
}
buffer.write(")");
});
if (buffer.length > 0) {
fail(buffer.toString());
}
}
/// Assert that the number of errors that have been gathered matches the
/// number of [expectedSeverities] and that there are the same number of
/// errors and warnings as specified by the argument. The order in which the
/// errors were gathered is ignored.
void assertErrorsWithSeverities(List<DiagnosticSeverity> expectedSeverities) {
int expectedErrorCount = 0;
int expectedWarningCount = 0;
for (DiagnosticSeverity severity in expectedSeverities) {
if (severity == DiagnosticSeverity.ERROR) {
expectedErrorCount++;
} else {
expectedWarningCount++;
}
}
int actualErrorCount = 0;
int actualWarningCount = 0;
for (Diagnostic diagnostic in _diagnostics) {
if (diagnostic.diagnosticCode.severity == DiagnosticSeverity.ERROR) {
actualErrorCount++;
} else {
actualWarningCount++;
}
}
if (expectedErrorCount != actualErrorCount ||
expectedWarningCount != actualWarningCount) {
fail(
"Expected $expectedErrorCount errors "
"and $expectedWarningCount warnings, "
"found $actualErrorCount errors "
"and $actualWarningCount warnings",
);
}
}
/// Assert that no errors have been gathered.
void assertNoErrors() {
assertErrors(ExpectedError.NO_ERRORS);
}
/// Return the line information associated with the given [source], or `null`
/// if no line information has been associated with the source.
LineInfo? getLineInfo(Source source) => _lineInfoMap[source];
@override
void onDiagnostic(Diagnostic diagnostic) {
_diagnostics.add(diagnostic);
}
/// Set the line information associated with the given [source] to [lineInfo].
void setLineInfo(Source source, LineInfo lineInfo) {
_lineInfoMap[source] = lineInfo;
}
}
/// Instances of the class [TestInstrumentor] implement an instrumentation
/// service that can be used by tests.
class TestInstrumentor extends NoopInstrumentationService {
/// All logged messages.
List<String> log = [];
@override
void logError(String message) {
log.add("error: $message");
}
@override
void logException(
dynamic exception, [
StackTrace? stackTrace,
List<InstrumentationServiceAttachment>? attachments,
]) {
log.add("error: $exception $stackTrace");
}
@override
void logInfo(String message, [dynamic exception]) {
log.add("info: $message");
}
}
class TestSource extends Source {
final String _name;
String _contents;
bool exists2 = true;
/// A flag indicating whether an exception should be generated when an attempt
/// is made to access the contents of this source.
bool generateExceptionOnRead = false;
/// The number of times that the contents of this source have been requested.
int readCount = 0;
TestSource([this._name = '/test.dart', this._contents = '']);
@override
TimestampedData<String> get contents {
readCount++;
if (generateExceptionOnRead) {
String msg = "I/O Exception while getting the contents of $_name";
throw Exception(msg);
}
return TimestampedData<String>(0, _contents);
}
@override
String get fullName {
return _name;
}
@override
int get hashCode => 0;
@override
String get shortName {
return _name;
}
@override
Uri get uri => Uri.file(_name);
@override
bool operator ==(Object other) {
if (other is TestSource) {
return other._name == _name;
}
return false;
}
@override
bool exists() => exists2;
Source resolve(String uri) {
throw UnsupportedError('resolve');
}
void setContents(String value) {
generateExceptionOnRead = false;
_contents = value;
}
@override
String toString() => _name;
}
class TestSourceWithUri extends TestSource {
@override
final Uri uri;
TestSourceWithUri(String path, this.uri, [String content = ''])
: super(path, content);
@override
bool operator ==(Object other) {
if (other is TestSource) {
return other.uri == uri;
}
return false;
}
}
extension on DiagnosticCode {
/// The name of the constant in the analyzer package (or other related
/// package) that represents this diagnostic code.
///
/// This string is used when generating test failure messages that suggest how
/// to change test expectations to match the current behavior.
///
/// For example, if the unique name is `TestClass.MY_ERROR`, this method will
/// return `TestClass.myError`.
String get constantName => switch (uniqueName.split('.')) {
[var className, var snakeCaseName] =>
'$className.${snakeCaseName.toCamelCase()}',
_ => throw StateError('Malformed DiagnosticCode: $uniqueName'),
};
}