blob: 1abe8e57c63a832ce2a0176614f50c55495ae438 [file] [log] [blame]
// Copyright (c) 2021, 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 json;
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/source/file_source.dart';
import 'package:analyzer/src/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/src/dart/analysis/byte_store.dart';
import 'package:analyzer/src/dart/analysis/driver_based_analysis_context.dart';
import 'package:analyzer/src/dart/analysis/experiments.dart';
import 'package:analyzer/src/error/codes.dart';
import 'package:analyzer/src/lint/pub.dart';
import 'package:analyzer/src/lint/registry.dart';
import 'package:analyzer/src/lint/util.dart';
import 'package:analyzer/src/test_utilities/mock_sdk.dart';
import 'package:analyzer/src/test_utilities/package_config_file_builder.dart';
import 'package:analyzer/src/test_utilities/resource_provider_mixin.dart';
import 'package:analyzer_utilities/test/experiments/experiments.dart';
import 'package:analyzer_utilities/test/mock_packages/mock_packages.dart';
import 'package:collection/collection.dart';
import 'package:linter/src/analyzer.dart';
import 'package:linter/src/rules.dart';
import 'package:meta/meta.dart';
import 'package:test/test.dart';
export 'package:analyzer/src/dart/error/syntactic_errors.dart';
export 'package:analyzer/src/error/codes.dart';
export 'package:analyzer/src/test_utilities/package_config_file_builder.dart';
export 'package:linter/src/lint_names.dart';
// TODO(srawlins): This is duplicate with
// pkg/analyzer/test/src/dart/resolution/context_collection_resolution.dart and
// and pkg/analysis_server/test/analysis_server_base.dart. Keep them as
// consistent with each other as they are today. Ultimately combine them in a
// shared analyzer test utilities package.
String analysisOptionsContent({
List<String> experiments = const [],
List<String> rules = const [],
}) {
var buffer = StringBuffer();
buffer.writeln('analyzer:');
buffer.writeln(' enable-experiment:');
for (var experiment in experiments) {
buffer.writeln(' - $experiment');
}
buffer.writeln(' optional-checks:');
buffer.writeln(' propagate-linter-exceptions: true');
buffer.writeln('linter:');
buffer.writeln(' rules:');
for (var rule in rules) {
buffer.writeln(' - $rule');
}
return buffer.toString();
}
ExpectedDiagnostic error(
ErrorCode code,
int offset,
int length, {
Pattern? messageContains,
}) => _ExpectedError(code, offset, length, messageContains: messageContains);
// TODO(srawlins): This is duplicate with
// pkg/analyzer/test/src/dart/resolution/context_collection_resolution.dart.
// Keep them as consistent with each other as they are today. Ultimately combine
// them in a shared analyzer test utilities package.
String pubspecYamlContent({String? name}) {
var buffer = StringBuffer();
if (name != null) {
buffer.writeln('name: $name');
}
return buffer.toString();
}
typedef DiagnosticMatcher = bool Function(AnalysisError error);
/// A description of a diagnostic that is expected to be reported.
class ExpectedDiagnostic {
final DiagnosticMatcher _diagnosticMatcher;
/// The offset of the beginning of the diagnostic's region.
final int _offset;
/// The offset of the beginning of the diagnostic's region.
final int _length;
/// A pattern that should be contained in the diagnostic message or `null` if
/// the message contents should not be checked.
final Pattern? _messageContains;
// 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;
/// Initialize a newly created diagnostic description.
ExpectedDiagnostic(
this._diagnosticMatcher,
this._offset,
this._length, {
Pattern? messageContains,
Pattern? correctionContains,
}) : _messageContains = messageContains,
_correctionContains = correctionContains;
/// Whether the [error] matches this description of what it's expected to be.
bool matches(AnalysisError error) {
if (!_diagnosticMatcher(error)) return false;
if (error.offset != _offset) return false;
if (error.length != _length) return false;
if (_messageContains != null && !error.message.contains(_messageContains)) {
return false;
}
if (_correctionContains != null) {
if (error.correction == null ||
!error.correction!.contains(_correctionContains)) {
return false;
}
}
return true;
}
}
mixin LanguageVersion219Mixin on PubPackageResolutionTest {
@override
String? get testPackageLanguageVersion => '2.19';
}
abstract class LintRuleTest extends PubPackageResolutionTest {
String get lintRule;
@override
List<String> get _lintRules {
var ruleName = lintRule;
if (!Registry.ruleRegistry.any((r) => r.name == ruleName)) {
throw Exception("Unrecognized rule: '$ruleName'");
}
return [ruleName];
}
ExpectedDiagnostic lint(
int offset,
int length, {
Pattern? messageContains,
Pattern? correctionContains,
String? name,
}) => _ExpectedLint(
name ?? lintRule,
offset,
length,
messageContains: messageContains,
correctionContains: correctionContains,
);
}
class PubPackageResolutionTest extends _ContextResolutionTest {
final List<String> _lintRules = const [];
bool get addFixnumPackageDep => false;
bool get addFlutterPackageDep => false;
bool get addJsPackageDep => false;
bool get addKernelPackageDep => false;
bool get addMetaPackageDep => false;
bool get addReflectiveTestLoaderPackageDep => false;
bool get dumpAstOnFailures => true;
List<String> get experiments => experimentsForTests;
String get testFileName => 'test.dart';
@override
String get testFilePath => '$testPackageLibPath/$testFileName';
String? get testPackageLanguageVersion => null;
String get testPackageLibPath => '$testPackageRootPath/lib';
String get testPackagePubspecPath => '$testPackageRootPath/pubspec.yaml';
String get testPackageRootPath => '$workspaceRootPath/test';
String get workspaceRootPath => '/home';
@override
List<String> get _collectionIncludedPaths => [workspaceRootPath];
/// Asserts that the number of diagnostics reported in [content] matches the
/// number of [expectedDiagnostics] and that they have the expected error
/// descriptions and locations.
///
/// The order in which the diagnostics were gathered is ignored.
Future<void> assertDiagnostics(
String content,
List<ExpectedDiagnostic> expectedDiagnostics,
) async {
addTestFile(content);
await resolveTestFile();
await _assertDiagnosticsIn(_errors, expectedDiagnostics);
}
/// Asserts that the number of diagnostics that have been gathered at [path]
/// matches the number of [expectedDiagnostics] and that they have the
/// expected error descriptions and locations.
///
/// The order in which the diagnostics were gathered is ignored.
Future<void> assertDiagnosticsInFile(
String path,
List<ExpectedDiagnostic> expectedDiagnostics,
) async {
await _resolveFile(path);
await _assertDiagnosticsIn(_errors, expectedDiagnostics);
}
/// Asserts that the diagnostics for each `path` match those in the paired
/// `expectedDiagnostics`.
///
/// The unit at each path needs to have already been written to the file
/// system before calling this method.
Future<void> assertDiagnosticsInUnits(
List<(String path, List<ExpectedDiagnostic> expectedDiagnostics)>
unitsAndDiagnostics,
) async {
for (var (path, expectedDiagnostics) in unitsAndDiagnostics) {
result = await resolveFile(convertPath(path));
await _assertDiagnosticsIn(result.errors, expectedDiagnostics);
}
}
/// Asserts that there are no diagnostics in the given [content].
Future<void> assertNoDiagnostics(String content) async =>
assertDiagnostics(content, const []);
/// Asserts that there are no diagnostics in the file at the given [path].
Future<void> assertNoDiagnosticsInFile(String path) async =>
assertDiagnosticsInFile(path, const []);
/// Asserts that no diagnostics are reported when resolving [content].
Future<void> assertNoPubspecDiagnostics(String content) async {
newFile(testPackagePubspecPath, content);
var errors = await _resolvePubspecFile(content);
await _assertDiagnosticsIn(errors, []);
}
/// Asserts that [expectedDiagnostics] are reported when resolving [content].
Future<void> assertPubspecDiagnostics(
String content,
List<ExpectedDiagnostic> expectedDiagnostics,
) async {
newFile(testPackagePubspecPath, content);
var errors = await _resolvePubspecFile(content);
await _assertDiagnosticsIn(errors, expectedDiagnostics);
}
@override
@mustCallSuper
void setUp() {
super.setUp();
// Check for any needlessly enabled experiments.
for (var experiment in experiments) {
var feature = ExperimentStatus.knownFeatures[experiment];
if (feature?.isEnabledByDefault ?? false) {
fail(
"The '$experiment' experiment is enabled by default, "
'try removing it from `experiments`.',
);
}
}
newAnalysisOptionsYamlFile(
testPackageRootPath,
analysisOptionsContent(experiments: experiments, rules: _lintRules),
);
writeTestPackageConfig(PackageConfigFileBuilder());
_writeTestPackagePubspecYamlFile(pubspecYamlContent(name: 'test'));
}
void writePackageConfig(String path, PackageConfigFileBuilder config) {
newFile(path, config.toContent(toUriStr: toUriStr));
}
void writeTestPackageConfig(PackageConfigFileBuilder config) {
var configCopy = config.copy();
configCopy.add(
name: 'test',
rootPath: testPackageRootPath,
languageVersion: testPackageLanguageVersion,
);
if (addFixnumPackageDep) {
var fixnumPath = addFixnum().parent.path;
configCopy.add(name: 'fixnum', rootPath: fixnumPath);
}
if (addFlutterPackageDep) {
var uiPath = addUI().parent.path;
configCopy.add(name: 'ui', rootPath: uiPath);
var flutterPath = addFlutter().parent.path;
configCopy.add(name: 'flutter', rootPath: flutterPath);
}
if (addJsPackageDep) {
var jsPath = addJs().parent.path;
configCopy.add(name: 'js', rootPath: jsPath);
}
if (addKernelPackageDep) {
var kernelPath = addKernel().parent.path;
configCopy.add(name: 'kernel', rootPath: kernelPath);
}
if (addMetaPackageDep) {
var metaPath = addMeta().parent.path;
configCopy.add(name: 'meta', rootPath: metaPath);
}
if (addReflectiveTestLoaderPackageDep) {
var testReflectiveLoaderPath =
'$workspaceRootPath/test_reflective_loader';
newFile('$testReflectiveLoaderPath/lib/test_reflective_loader.dart', r'''
library test_reflective_loader;
const Object reflectiveTest = _ReflectiveTest();
class _ReflectiveTest {
const _ReflectiveTest();
}
''');
configCopy.add(
name: 'test_reflective_loader',
rootPath: testReflectiveLoaderPath,
);
}
var path = '$testPackageRootPath/.dart_tool/package_config.json';
writePackageConfig(path, configCopy);
}
/// Asserts that the diagnostics in [errors] match [expectedDiagnostics].
Future<void> _assertDiagnosticsIn(
List<AnalysisError> errors,
List<ExpectedDiagnostic> expectedDiagnostics,
) async {
//
// Match actual diagnostics to expected diagnostics.
//
var unmatchedActual = errors.toList();
var unmatchedExpected = expectedDiagnostics.toList();
var actualIndex = 0;
while (actualIndex < unmatchedActual.length) {
var matchFound = false;
var 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.
//
var buffer = StringBuffer();
if (unmatchedExpected.isNotEmpty) {
buffer.writeln('Expected but did not find:');
for (var expected in unmatchedExpected) {
buffer.write(' ');
if (expected is _ExpectedError) {
buffer.write(expected._code);
}
if (expected is _ExpectedLint) {
buffer.write(expected._lintName);
}
buffer.write(' [');
buffer.write(expected._offset);
buffer.write(', ');
buffer.write(expected._length);
if (expected._messageContains != null) {
buffer.write(', messageContains: ');
buffer.write(json.encode(expected._messageContains.toString()));
}
if (expected._correctionContains != null) {
buffer.write(', correctionContains: ');
buffer.write(json.encode(expected._correctionContains.toString()));
}
buffer.writeln(']');
}
}
if (unmatchedActual.isNotEmpty) {
if (buffer.isNotEmpty) {
buffer.writeln();
}
buffer.writeln('Found but did not expect:');
for (var actual in unmatchedActual) {
buffer.write(' ');
buffer.write(actual.errorCode);
buffer.write(' [');
buffer.write(actual.offset);
buffer.write(', ');
buffer.write(actual.length);
buffer.write(', ');
buffer.write(actual.message);
if (actual.correctionMessage != null) {
buffer.write(', ');
buffer.write(json.encode(actual.correctionMessage));
}
buffer.writeln(']');
}
}
if (buffer.isNotEmpty) {
errors.sort((first, second) => first.offset.compareTo(second.offset));
buffer.writeln();
buffer.writeln('To accept the current state, expect:');
for (var actual in errors) {
late String diagnosticKind;
Object? description;
if (actual.errorCode is LintCode) {
diagnosticKind = 'lint';
} else {
diagnosticKind = 'error';
description = actual.errorCode;
}
buffer.write(' $diagnosticKind(');
if (description != null) {
buffer.write(description);
buffer.write(', ');
}
buffer.write(actual.offset);
buffer.write(', ');
buffer.write(actual.length);
buffer.writeln('),');
}
if (dumpAstOnFailures) {
buffer.writeln();
buffer.writeln();
try {
var astSink = StringBuffer();
StringSpelunker(
result.unit.toSource(),
sink: astSink,
featureSet: result.unit.featureSet,
).spelunk();
buffer.write(astSink);
buffer.writeln();
// I hereby choose to catch this type.
// ignore: avoid_catching_errors
} on ArgumentError catch (_) {
// Perhaps we encountered a parsing error while spelunking.
}
}
fail(buffer.toString());
}
}
Future<List<AnalysisError>> _resolvePubspecFile(String content) async {
var path = convertPath(testPackagePubspecPath);
var pubspecRules = <LintRule, PubspecVisitor<Object?>>{};
for (var rule in Registry.ruleRegistry.where(
(rule) => _lintRules.contains(rule.name),
)) {
var visitor = rule.getPubspecVisitor();
if (visitor != null) {
pubspecRules[rule] = visitor;
}
}
if (pubspecRules.isEmpty) {
throw UnsupportedError(
'Resolving pubspec files only supported with rules with '
'PubspecVisitors.',
);
}
var sourceUri = resourceProvider.pathContext.toUri(path);
var pubspecAst = Pubspec.parse(
content,
sourceUrl: sourceUri,
resourceProvider: resourceProvider,
);
var listener = RecordingErrorListener();
var file = resourceProvider.getFile(path);
var reporter = ErrorReporter(listener, FileSource(file, sourceUri));
for (var entry in pubspecRules.entries) {
entry.key.reporter = reporter;
pubspecAst.accept(entry.value);
}
return [...listener.errors];
}
void _writeTestPackagePubspecYamlFile(String content) {
newPubspecYamlFile(testPackageRootPath, content);
}
}
abstract class _ContextResolutionTest
with MockPackagesMixin, ResourceProviderMixin {
static bool _lintRulesAreRegistered = false;
/// The byte store that is reused between tests. This allows reusing all
/// unlinked and linked summaries for SDK, so that tests run much faster.
/// However nothing is preserved between Dart VM runs, so changes to the
/// implementation are still fully verified.
static final MemoryByteStore _sharedByteStore = MemoryByteStore();
final MemoryByteStore _byteStore = _sharedByteStore;
AnalysisContextCollectionImpl? _analysisContextCollection;
late ResolvedUnitResult result;
/// Error codes that by default should be ignored in test expectations.
List<ErrorCode> get ignoredErrorCodes => [WarningCode.UNUSED_LOCAL_VARIABLE];
/// The path to the root of the external packages.
@override
String get packagesRootPath => '/packages';
String get testFilePath;
List<String> get _collectionIncludedPaths;
/// The analysis errors that were computed during analysis.
List<AnalysisError> get _errors =>
result.errors
.whereNot((e) => ignoredErrorCodes.any((c) => e.errorCode == c))
.toList();
Folder get _sdkRoot => newFolder('/sdk');
void addTestFile(String content) {
newFile(testFilePath, content);
}
@override
File newFile(String path, String content) {
if (_analysisContextCollection != null && !path.endsWith('.dart')) {
throw StateError('Only dart files can be changed after analysis.');
}
return super.newFile(path, content);
}
/// Resolves a Dart source file at [path].
///
/// [path] must be converted for this file system.
Future<ResolvedUnitResult> resolveFile(String path) async {
var analysisContext = _contextFor(path);
var session = analysisContext.currentSession;
return await session.getResolvedUnit(path) as ResolvedUnitResult;
}
Future<void> resolveTestFile() => _resolveFile(testFilePath);
@mustCallSuper
void setUp() {
if (!_lintRulesAreRegistered) {
registerLintRules();
_lintRulesAreRegistered = true;
}
createMockSdk(resourceProvider: resourceProvider, root: _sdkRoot);
}
@mustCallSuper
Future<void> tearDown() async {
await _analysisContextCollection?.dispose();
_analysisContextCollection = null;
}
DriverBasedAnalysisContext _contextFor(String path) {
_createAnalysisContexts();
var convertedPath = convertPath(path);
return _analysisContextCollection!.contextFor(convertedPath);
}
/// Creates all analysis contexts in [_collectionIncludedPaths].
void _createAnalysisContexts() {
if (_analysisContextCollection != null) {
return;
}
_analysisContextCollection = AnalysisContextCollectionImpl(
byteStore: _byteStore,
declaredVariables: {},
enableIndex: true,
includedPaths: _collectionIncludedPaths.map(convertPath).toList(),
resourceProvider: resourceProvider,
sdkPath: _sdkRoot.path,
);
}
/// Resolves the file with the [path] into [result].
Future<void> _resolveFile(String path) async {
var convertedPath = convertPath(path);
result = await resolveFile(convertedPath);
}
}
/// A description of an expected error.
final class _ExpectedError extends ExpectedDiagnostic {
final ErrorCode _code;
_ExpectedError(this._code, int offset, int length, {Pattern? messageContains})
: super(
(error) => error.errorCode == _code,
offset,
length,
messageContains: messageContains,
);
}
/// A description of an expected lint rule violation.
final class _ExpectedLint extends ExpectedDiagnostic {
final String _lintName;
_ExpectedLint(
this._lintName,
int offset,
int length, {
super.messageContains,
super.correctionContains,
}) : super((error) => error.errorCode.name == _lintName, offset, length);
}