blob: 8b8624c7f5bd16148f4c204ef2b19ba2b8514cbe [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:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:analysis_server/protocol/protocol_constants.dart';
import 'package:analysis_server/protocol/protocol_generated.dart';
import 'package:analysis_server/src/services/pub/pub_command.dart';
import 'package:analyzer/src/test_utilities/package_config_file_builder.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
import 'integration_test_methods.dart';
import 'protocol_matchers.dart';
const Matcher isBool = TypeMatcher<bool>();
const Matcher isDouble = TypeMatcher<double>();
const Matcher isInt = TypeMatcher<int>();
const Matcher isNotification = MatchesJsonObject(
'notification', {'event': isString},
optionalFields: {'params': isMap});
const Matcher isString = TypeMatcher<String>();
final Matcher isResponse = MatchesJsonObject('response', {'id': isString},
optionalFields: {'result': anything, 'error': isRequestError});
Matcher isListOf(Matcher elementMatcher) => _ListOf(elementMatcher);
Matcher isMapOf(Matcher keyMatcher, Matcher valueMatcher) =>
_MapOf(keyMatcher, valueMatcher);
Matcher isOneOf(List<Matcher> choiceMatchers) => _OneOf(choiceMatchers);
/// Assert that [actual] matches [matcher].
void outOfTestExpect(Object? actual, Matcher matcher,
{String? reason, skip, bool verbose = false}) {
var matchState = {};
try {
if (matcher.matches(actual, matchState)) return;
} catch (e, trace) {
reason ??= '${(e is String) ? e : e.toString()} at $trace';
}
fail(_defaultFailFormatter(actual, matcher, reason, matchState, verbose));
}
String _defaultFailFormatter(
actual, Matcher matcher, String? reason, Map matchState, bool verbose) {
var description = StringDescription();
description.add('Expected: ').addDescriptionOf(matcher).add('\n');
description.add(' Actual: ').addDescriptionOf(actual).add('\n');
var mismatchDescription = StringDescription();
matcher.describeMismatch(actual, mismatchDescription, matchState, verbose);
if (mismatchDescription.length > 0) {
description.add(' Which: $mismatchDescription\n');
}
if (reason != null) description.add(reason).add('\n');
return description.toString();
}
/// Type of closures used by LazyMatcher.
typedef MatcherCreator = Matcher Function();
/// Type of closures used by MatchesJsonObject to record field mismatches.
typedef MismatchDescriber = Description Function(
Description mismatchDescription);
/// Type of callbacks used to process notifications.
typedef NotificationProcessor = void Function(String event, Map params);
/// Base class for analysis server integration tests.
abstract class AbstractAnalysisServerIntegrationTest
extends IntegrationTestMixin {
/// Amount of time to give the server to respond to a shutdown request before
/// forcibly terminating it.
static const Duration SHUTDOWN_TIMEOUT = Duration(seconds: 60);
/// Connection to the analysis server.
@override
final Server server = Server();
/// Temporary directory in which source files can be stored.
late Directory sourceDirectory;
/// Map from file path to the list of analysis errors which have most recently
/// been received for the file.
Map<String, List<AnalysisError>> currentAnalysisErrors =
HashMap<String, List<AnalysisError>>();
/// The last list of analyzed files received.
late List<String> lastAnalyzedFiles;
/// True if the teardown process should skip sending a "server.shutdown"
/// request (e.g. because the server is known to have already shutdown).
bool skipShutdown = false;
/// True if we are currently subscribed to [SERVER_NOTIFICATION_STATUS]
/// updates.
bool _subscribedToServerStatus = false;
String dartSdkPath = path.dirname(path.dirname(Platform.resolvedExecutable));
AbstractAnalysisServerIntegrationTest() {
initializeInttestMixin();
}
/// Return a future which will complete when a 'server.status' notification is
/// received from the server with 'analyzing' set to false.
///
/// The future will only be completed by 'server.status' notifications that
/// are received after this function call. So it is safe to use this getter
/// multiple times in one test; each time it is used it will wait afresh for
/// analysis to finish.
Future<ServerStatusParams> get analysisFinished {
var completer = Completer<ServerStatusParams>();
late StreamSubscription<ServerStatusParams> subscription;
// This will only work if the caller has already subscribed to
// SERVER_STATUS (e.g. using sendServerSetSubscriptions(['STATUS']))
outOfTestExpect(_subscribedToServerStatus, isTrue);
subscription = onServerStatus.listen((ServerStatusParams params) {
var analysisStatus = params.analysis;
if (analysisStatus != null && !analysisStatus.isAnalyzing) {
completer.complete(params);
subscription.cancel();
}
});
return completer.future;
}
/// Print out any messages exchanged with the server. If some messages have
/// already been exchanged with the server, they are printed out immediately.
void debugStdio() {
server.debugStdio();
}
/// If there was a set of errors (might be empty) received for the file
/// with the given [path], return it. If no errors - fail.
List<AnalysisError> existingErrorsForFile(String path) {
var errors = currentAnalysisErrors[path];
if (errors == null) {
fail('Expected errors for: $path');
}
return errors;
}
List<AnalysisError>? getErrors(String pathname) =>
currentAnalysisErrors[pathname];
/// Read a source file with the given absolute [pathname].
String readFile(String pathname) => File(pathname).readAsStringSync();
@override
Future sendServerSetSubscriptions(List<ServerService> subscriptions) {
_subscribedToServerStatus = subscriptions.contains(ServerService.STATUS);
return super.sendServerSetSubscriptions(subscriptions);
}
/// The server is automatically started before every test, and a temporary
/// [sourceDirectory] is created.
Future setUp() async {
sourceDirectory = Directory(Directory.systemTemp
.createTempSync('analysisServer')
.resolveSymbolicLinksSync());
writeTestPackageConfig();
onAnalysisErrors.listen((AnalysisErrorsParams params) {
currentAnalysisErrors[params.file] = params.errors;
});
onAnalysisAnalyzedFiles.listen((AnalysisAnalyzedFilesParams params) {
lastAnalyzedFiles = params.directories;
});
var serverConnected = Completer();
onServerConnected.listen((_) {
outOfTestExpect(serverConnected.isCompleted, isFalse);
serverConnected.complete();
});
onServerError.listen((ServerErrorParams params) {
// A server error should never happen during an integration test.
fail('${params.message}\n${params.stackTrace}');
});
await startServer();
server.listenToOutput(dispatchNotification);
server.exitCode.then((_) {
skipShutdown = true;
});
return serverConnected.future;
}
/// If [skipShutdown] is not set, shut down the server.
Future shutdownIfNeeded() {
if (skipShutdown) {
return Future.value();
}
// Give the server a short time to comply with the shutdown request; if it
// doesn't exit, then forcibly terminate it.
sendServerShutdown();
return server.exitCode.timeout(SHUTDOWN_TIMEOUT, onTimeout: () {
// The integer value of the exit code isn't used, but we have to return
// an integer to keep the typing correct.
return server.kill('server failed to exit').then((_) => -1);
});
}
/// Convert the given [relativePath] to an absolute path, by interpreting it
/// relative to [sourceDirectory]. On Windows any forward slashes in
/// [relativePath] are converted to backslashes.
String sourcePath(String relativePath) {
return path.join(
sourceDirectory.path, relativePath.replaceAll('/', path.separator));
}
/// Send the server an 'analysis.setAnalysisRoots' command directing it to
/// analyze [sourceDirectory]. If [subscribeStatus] is true (the default),
/// then also enable [SERVER_NOTIFICATION_STATUS] notifications so that
/// [analysisFinished] can be used.
Future standardAnalysisSetup({bool subscribeStatus = true}) {
var futures = <Future>[];
if (subscribeStatus) {
futures.add(sendServerSetSubscriptions([ServerService.STATUS]));
}
futures.add(sendAnalysisSetAnalysisRoots([sourceDirectory.path], []));
return Future.wait(futures);
}
/// Start [server].
Future startServer({
int? diagnosticPort,
int? servicesPort,
}) {
return server.start(
dartSdkPath: dartSdkPath,
diagnosticPort: diagnosticPort,
servicesPort: servicesPort,
);
}
/// After every test, the server is stopped and [sourceDirectory] is deleted.
Future tearDown() {
return shutdownIfNeeded().then((_) {
sourceDirectory.deleteSync(recursive: true);
});
}
/// Write a source file with the given absolute [pathname] and [contents].
///
/// If the file didn't previously exist, it is created. If it did, it is
/// overwritten.
///
/// Parent directories are created as necessary.
///
/// Return a normalized path to the file (with symbolic links resolved).
String writeFile(String pathname, String contents) {
Directory(path.dirname(pathname)).createSync(recursive: true);
var file = File(pathname);
file.writeAsStringSync(contents);
return file.resolveSymbolicLinksSync();
}
void writePackageConfig(
String pathname, {
required PackageConfigFileBuilder config,
}) {
writeFile(
pathname,
config.toContent(
toUriStr: (p) => '${path.toUri(p)}',
),
);
}
void writeTestPackageConfig({
PackageConfigFileBuilder? config,
String? languageVersion,
}) {
if (config == null) {
config = PackageConfigFileBuilder();
} else {
config = config.copy();
}
config.add(
name: 'test',
rootPath: sourceDirectory.path,
languageVersion: languageVersion,
);
writePackageConfig(
sourcePath('.dart_tool/package_config.json'),
config: config,
);
}
}
/// Wrapper class for Matcher which doesn't create the underlying Matcher object
/// until it is needed. This is necessary in order to create matchers that can
/// refer to themselves (so that recursive data structures can be represented).
class LazyMatcher implements Matcher {
/// Callback that will be used to create the matcher the first time it is
/// needed.
final MatcherCreator _creator;
/// The matcher returned by [_creator], if it has already been called.
/// Otherwise null.
Matcher? _wrappedMatcher;
LazyMatcher(this._creator);
/// Create the wrapped matcher object, if it hasn't been created already.
Matcher get _matcher {
return _wrappedMatcher ??= _creator();
}
@override
Description describe(Description description) {
return _matcher.describe(description);
}
@override
Description describeMismatch(
item, Description mismatchDescription, Map matchState, bool verbose) {
return _matcher.describeMismatch(
item, mismatchDescription, matchState, verbose);
}
@override
bool matches(item, Map matchState) {
return _matcher.matches(item, matchState);
}
}
/// Matcher that matches a String drawn from a limited set.
class MatchesEnum extends Matcher {
/// Short description of the expected type.
final String description;
/// The set of enum values that are allowed.
final List<String> allowedValues;
const MatchesEnum(this.description, this.allowedValues);
@override
Description describe(Description description) =>
description.add(this.description);
@override
bool matches(item, Map matchState) {
return allowedValues.contains(item);
}
}
/// Matcher that matches a JSON object, with a given set of required and
/// optional fields, and their associated types (expressed as [Matcher]s).
class MatchesJsonObject extends _RecursiveMatcher {
/// Short description of the expected type.
final String description;
/// Fields that are required to be in the JSON object, and [Matcher]s
/// describing their expected types.
final Map<String, Matcher>? requiredFields;
/// Fields that are optional in the JSON object, and [Matcher]s describing
/// their expected types.
final Map<String, Matcher>? optionalFields;
const MatchesJsonObject(this.description, this.requiredFields,
{this.optionalFields});
@override
Description describe(Description description) =>
description.add(this.description);
@override
void populateMismatches(Object? item, List<MismatchDescriber> mismatches) {
if (item is! Map<String, Object?>) {
mismatches.add(simpleDescription('is not a map'));
return;
}
final requiredFields = this.requiredFields;
final optionalFields = this.optionalFields;
if (requiredFields != null) {
requiredFields.forEach((String key, Matcher valueMatcher) {
if (!item.containsKey(key)) {
mismatches.add((Description mismatchDescription) =>
mismatchDescription
.add('is missing field ')
.addDescriptionOf(key)
.add(' (')
.addDescriptionOf(valueMatcher)
.add(')'));
} else {
_checkField(key, item[key], valueMatcher, mismatches);
}
});
}
item.forEach((key, value) {
if (requiredFields != null && requiredFields.containsKey(key)) {
// Already checked this field
return;
}
if (optionalFields != null) {
var optionalValue = optionalFields[key];
if (optionalValue != null) {
_checkField(key, value, optionalValue, mismatches);
return;
}
}
mismatches.add((Description mismatchDescription) => mismatchDescription
.add('has unexpected field ')
.addDescriptionOf(key));
});
}
/// Check the type of a field called [key], having value [value], using
/// [valueMatcher]. If it doesn't match, record a closure in [mismatches]
/// which can describe the mismatch.
void _checkField(String key, value, Matcher valueMatcher,
List<MismatchDescriber> mismatches) {
checkSubstructure(
value,
valueMatcher,
mismatches,
(Description description) =>
description.add('field ').addDescriptionOf(key));
}
}
/// Instances of the class [Server] manage a connection to a server process, and
/// facilitate communication to and from the server.
class Server {
/// Server process object, or null if server hasn't been started yet.
late final Process _process;
/// Commands that have been sent to the server but not yet acknowledged, and
/// the [Completer] objects which should be completed when acknowledgement is
/// received.
final Map<String, Completer<Map<String, Object?>?>> _pendingCommands = {};
/// Number which should be used to compute the 'id' to send in the next
/// command sent to the server.
int _nextId = 0;
/// Messages which have been exchanged with the server; we buffer these
/// up until the test finishes, so that they can be examined in the debugger
/// or printed out in response to a call to [debugStdio].
final List<String> _recordedStdio = <String>[];
/// True if we are currently printing out messages exchanged with the server.
bool _debuggingStdio = false;
/// True if we've received bad data from the server, and we are aborting the
/// test.
bool _receivedBadDataFromServer = false;
/// Stopwatch that we use to generate timing information for debug output.
final Stopwatch _time = Stopwatch();
/// The [currentElapseTime] at which the last communication was received from
/// the server or `null` if no communication has been received.
double? lastCommunicationTime;
/// The current elapse time (seconds) since the server was started.
double get currentElapseTime => _time.elapsedTicks / _time.frequency;
/// Future that completes when the server process exits.
Future<int> get exitCode => _process.exitCode;
/// Print out any messages exchanged with the server. If some messages have
/// already been exchanged with the server, they are printed out immediately.
void debugStdio() {
if (_debuggingStdio) {
return;
}
_debuggingStdio = true;
for (var line in _recordedStdio) {
print(line);
}
}
/// Find the root directory of the analysis_server package by proceeding
/// upward to the 'test' dir, and then going up one more directory.
String findRoot(String pathname) {
while (!['benchmark', 'test'].contains(path.basename(pathname))) {
var parent = path.dirname(pathname);
if (parent.length >= pathname.length) {
throw Exception("Can't find root directory");
}
pathname = parent;
}
return path.dirname(pathname);
}
/// Return a future that will complete when all commands that have been sent
/// to the server so far have been flushed to the OS buffer.
Future flushCommands() {
return _process.stdin.flush();
}
/// Stop the server.
Future<int> kill(String reason) {
debugStdio();
_recordStdio('FORCIBLY TERMINATING PROCESS: $reason');
_process.kill();
return _process.exitCode;
}
/// Start listening to output from the server, and deliver notifications to
/// [notificationProcessor].
void listenToOutput(NotificationProcessor notificationProcessor) {
_process.stdout
.transform(utf8.decoder)
.transform(LineSplitter())
.listen((String line) {
lastCommunicationTime = currentElapseTime;
var trimmedLine = line.trim();
// Guard against lines like:
// {"event":"server.connected","params":{...}}The Dart VM service is listening on ...
var dartVMServiceMessage = 'The Dart VM service is listening on ';
if (trimmedLine.contains(dartVMServiceMessage)) {
trimmedLine = trimmedLine
.substring(0, trimmedLine.indexOf(dartVMServiceMessage))
.trim();
}
if (trimmedLine.isEmpty) {
return;
}
_recordStdio('<== $trimmedLine');
Map message;
try {
message = json.decoder.convert(trimmedLine) as Map<Object?, Object?>;
} catch (exception) {
_badDataFromServer('JSON decode failure: $exception');
return;
}
outOfTestExpect(message, isMap);
if (message.containsKey('id')) {
outOfTestExpect(message['id'], isString);
var id = message['id'] as String;
var completer = _pendingCommands[id];
if (completer == null) {
fail('Unexpected response from server: id=$id');
} else {
_pendingCommands.remove(id);
}
if (message.containsKey('error')) {
completer.completeError(ServerErrorMessage(message));
} else {
completer.complete(message['result'] as Map<String, Object?>?);
}
// Check that the message is well-formed. We do this after calling
// completer.complete() or completer.completeError() so that we don't
// stall the test in the event of an error.
outOfTestExpect(message, isResponse);
} else {
// Message is a notification. It should have an event and possibly
// params.
outOfTestExpect(message, contains('event'));
outOfTestExpect(message['event'], isString);
notificationProcessor(message['event'] as String,
message['params'] as Map<Object?, Object?>);
// Check that the message is well-formed. We do this after calling
// notificationController.add() so that we don't stall the test in the
// event of an error.
outOfTestExpect(message, isNotification);
}
});
_process.stderr
.transform(Utf8Codec().decoder)
.transform(LineSplitter())
.listen((String line) {
var trimmedLine = line.trim();
_recordStdio('ERR: $trimmedLine');
_badDataFromServer('Message received on stderr', silent: true);
});
}
/// Send a command to the server. An 'id' will be automatically assigned.
/// The returned [Future] will be completed when the server acknowledges the
/// command with a response. If the server acknowledges the command with a
/// normal (non-error) response, the future will be completed with the
/// 'result' field from the response. If the server acknowledges the command
/// with an error response, the future will be completed with an error.
Future<Map<String, Object?>?> send(
String method, Map<String, Object?>? params) {
var id = '${_nextId++}';
var command = <String, Object?>{'id': id, 'method': method};
if (params != null) {
command['params'] = params;
}
var completer = Completer<Map<String, Object?>?>();
_pendingCommands[id] = completer;
var line = json.encode(command);
_recordStdio('==> $line');
_process.stdin.add(utf8.encoder.convert('$line\n'));
return completer.future;
}
/// Start the server. If [profileServer] is `true`, the server will be started
/// with "--observe" and "--pause-isolates-on-exit", allowing the observatory
/// to be used.
Future start({
required String dartSdkPath,
int? diagnosticPort,
String? instrumentationLogFile,
String? packagesFile,
bool profileServer = false,
int? servicesPort,
bool useAnalysisHighlight2 = false,
}) async {
_time.start();
var dartBinary = path.join(dartSdkPath, 'bin', 'dart');
// Setting the `TEST_SERVER_SNAPSHOT` env var to 'false' will disable the
// snapshot and run from source.
var useSnapshot = Platform.environment['TEST_SERVER_SNAPSHOT'] != 'false';
String serverPath;
if (useSnapshot) {
serverPath = path.normalize(path.join(
dartSdkPath, 'bin', 'snapshots', 'analysis_server.dart.snapshot'));
} else {
var rootDir =
findRoot(Platform.script.toFilePath(windows: Platform.isWindows));
serverPath = path.normalize(path.join(rootDir, 'bin', 'server.dart'));
}
var arguments = <String>[
'--disable-dart-dev',
];
//
// Add VM arguments.
//
if (profileServer) {
if (servicesPort == null) {
arguments.add('--observe');
} else {
arguments.add('--observe=$servicesPort');
}
arguments.add('--pause-isolates-on-exit');
} else if (servicesPort != null) {
arguments.add('--enable-vm-service=$servicesPort');
}
if (Platform.packageConfig != null) {
arguments.add('--packages=${Platform.packageConfig}');
}
arguments.add('--disable-service-auth-codes');
//
// Add the server executable.
//
arguments.add(serverPath);
//
// Add server arguments.
//
arguments.add('--suppress-analytics');
arguments.add('--sdk=$dartSdkPath');
if (diagnosticPort != null) {
arguments.add('--port');
arguments.add(diagnosticPort.toString());
}
if (instrumentationLogFile != null) {
arguments.add('--instrumentation-log-file=$instrumentationLogFile');
}
if (packagesFile != null) {
arguments.add('--packages=$packagesFile');
}
if (useAnalysisHighlight2) {
arguments.add('--useAnalysisHighlight2');
}
_process = await Process.start(
dartBinary,
arguments,
environment: {PubCommand.disablePubCommandEnvironmentKey: 'true'},
);
_process.exitCode.then((int code) {
if (code != 0) {
_badDataFromServer('server terminated with exit code $code');
}
});
}
/// Deal with bad data received from the server.
void _badDataFromServer(String details, {bool silent = false}) {
if (!silent) {
_recordStdio('BAD DATA FROM SERVER: $details');
}
if (_receivedBadDataFromServer) {
// We're already dealing with it.
return;
}
_receivedBadDataFromServer = true;
debugStdio();
// Give the server 1 second to continue outputting bad data before we kill
// the test. This is helpful if the server has had an unhandled exception
// and is outputting a stacktrace, because it ensures that we see the
// entire stacktrace. Use expectAsync() to prevent the test from
// ending during this 1 second.
Future.delayed(Duration(seconds: 1), expectAsync0(() {
fail('Bad data received from server: $details');
}));
}
/// Record a message that was exchanged with the server, and print it out if
/// [debugStdio] has been called.
void _recordStdio(String line) {
var elapsedTime = currentElapseTime;
line = '$elapsedTime: $line';
if (_debuggingStdio) {
print(line);
}
_recordedStdio.add(line);
}
}
/// An error result from a server request.
class ServerErrorMessage {
final Map message;
ServerErrorMessage(this.message);
dynamic get error => message['error'];
@override
String toString() => message.toString();
}
/// Matcher that matches a list of objects, each of which satisfies the given
/// matcher.
class _ListOf extends Matcher {
/// Matcher which every element of the list must satisfy.
final Matcher elementMatcher;
/// Iterable matcher which we use to test the contents of the list.
final Matcher iterableMatcher;
_ListOf(this.elementMatcher) : iterableMatcher = everyElement(elementMatcher);
@override
Description describe(Description description) =>
description.add('List of ').addDescriptionOf(elementMatcher);
@override
Description describeMismatch(
item, Description mismatchDescription, Map matchState, bool verbose) {
if (item is! List) {
return super
.describeMismatch(item, mismatchDescription, matchState, verbose);
} else {
return iterableMatcher.describeMismatch(
item, mismatchDescription, matchState, verbose);
}
}
@override
bool matches(item, Map matchState) {
if (item is! List) {
return false;
}
return iterableMatcher.matches(item, matchState);
}
}
/// Matcher that matches a map of objects, where each key/value pair in the
/// map satisfies the given key and value matchers.
class _MapOf extends _RecursiveMatcher {
/// Matcher which every key in the map must satisfy.
final Matcher keyMatcher;
/// Matcher which every value in the map must satisfy.
final Matcher valueMatcher;
_MapOf(this.keyMatcher, this.valueMatcher);
@override
Description describe(Description description) => description
.add('Map from ')
.addDescriptionOf(keyMatcher)
.add(' to ')
.addDescriptionOf(valueMatcher);
@override
void populateMismatches(item, List<MismatchDescriber> mismatches) {
if (item is! Map) {
mismatches.add(simpleDescription('is not a map'));
return;
}
item.forEach((key, value) {
checkSubstructure(
key,
keyMatcher,
mismatches,
(Description description) =>
description.add('key ').addDescriptionOf(key));
checkSubstructure(
value,
valueMatcher,
mismatches,
(Description description) =>
description.add('field ').addDescriptionOf(key));
});
}
}
/// Matcher that matches a union of different types, each of which is described
/// by a matcher.
class _OneOf extends Matcher {
/// Matchers for the individual choices.
final List<Matcher> choiceMatchers;
_OneOf(this.choiceMatchers);
@override
Description describe(Description description) {
for (var i = 0; i < choiceMatchers.length; i++) {
if (i != 0) {
if (choiceMatchers.length == 2) {
description = description.add(' or ');
} else {
description = description.add(', ');
if (i == choiceMatchers.length - 1) {
description = description.add('or ');
}
}
}
description = description.addDescriptionOf(choiceMatchers[i]);
}
return description;
}
@override
bool matches(item, Map matchState) {
for (var choiceMatcher in choiceMatchers) {
var subState = {};
if (choiceMatcher.matches(item, subState)) {
return true;
}
}
return false;
}
}
/// Base class for matchers that operate by recursing through the contents of
/// an object.
abstract class _RecursiveMatcher extends Matcher {
const _RecursiveMatcher();
/// Check the type of a substructure whose value is [item], using [matcher].
/// If it doesn't match, record a closure in [mismatches] which can describe
/// the mismatch. [describeSubstructure] is used to describe which
/// substructure did not match.
void checkSubstructure(
item,
Matcher matcher,
List<MismatchDescriber> mismatches,
Description Function(Description) describeSubstructure) {
var subState = {};
if (!matcher.matches(item, subState)) {
mismatches.add((Description mismatchDescription) {
mismatchDescription = mismatchDescription.add('contains malformed ');
mismatchDescription = describeSubstructure(mismatchDescription);
mismatchDescription =
mismatchDescription.add(' (should be ').addDescriptionOf(matcher);
var subDescription = matcher
.describeMismatch(item, StringDescription(), subState, false)
.toString();
if (subDescription.isNotEmpty) {
mismatchDescription =
mismatchDescription.add('; ').add(subDescription);
}
return mismatchDescription.add(')');
});
}
}
@override
Description describeMismatch(
item, Description mismatchDescription, Map matchState, bool verbose) {
var mismatches = matchState['mismatches'] as List<MismatchDescriber>?;
if (mismatches != null) {
for (var i = 0; i < mismatches.length; i++) {
var mismatch = mismatches[i];
if (i > 0) {
if (mismatches.length == 2) {
mismatchDescription = mismatchDescription.add(' and ');
} else if (i == mismatches.length - 1) {
mismatchDescription = mismatchDescription.add(', and ');
} else {
mismatchDescription = mismatchDescription.add(', ');
}
}
mismatchDescription = mismatch(mismatchDescription);
}
return mismatchDescription;
} else {
return super
.describeMismatch(item, mismatchDescription, matchState, verbose);
}
}
@override
bool matches(item, Map matchState) {
var mismatches = <MismatchDescriber>[];
populateMismatches(item, mismatches);
if (mismatches.isEmpty) {
return true;
} else {
addStateInfo(matchState, {'mismatches': mismatches});
return false;
}
}
/// Populate [mismatches] with descriptions of all the ways in which [item]
/// does not match.
void populateMismatches(item, List<MismatchDescriber> mismatches);
/// Create a [MismatchDescriber] describing a mismatch with a simple string.
MismatchDescriber simpleDescription(String description) =>
(Description mismatchDescription) {
mismatchDescription.add(description);
return mismatchDescription;
};
}