blob: f2d5d21a2dc3105d7e1f12055178c95be4e923a6 [file] [log] [blame]
// Copyright (c) 2022, 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:io' as io;
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/src/dart/ast/ast.dart';
import 'package:analyzer/src/utilities/extensions/collection.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
class NodeTextExpectationsCollector {
/// If this flag is `true`, we accumulate updates to expectations.
/// This should only happen locally, to update tests or implementation.
///
/// This flag should be `false` during code review.
static const updatingIsEnabled = false;
static final assertMethods = [
_AssertMethod.forFunction(
methodName: 'assertEdits',
argument: _ArgumentNamed('expected'),
),
_AssertMethod(
className: 'AnalysisContextCollectionTest',
methodName: '_assertWorkspaceCollectionText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'AnalysisSessionImplTest',
methodName: '_assertFileUnitElementResultText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ContextResolutionTest',
methodName: 'assertDriverStateString',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ElementsBaseTest',
methodName: 'checkElementText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ElementLocatorTest',
methodName: '_assertElement',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ElementLocatorTest2',
methodName: '_assertElement',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'FileResolutionTest',
methodName: 'assertStateString',
argument: _ArgumentIndex(0),
),
_AssertMethod(
className: 'FineAnalysisDriverTest',
methodName: '_runChangeScenarioTA',
argument: _ArgumentNamed(
'expectedInitialEvents',
shouldCheckIntraInvocationId: true,
),
),
_AssertMethod(
className: 'FineAnalysisDriverTest',
methodName: '_runChangeScenarioTA',
argument: _ArgumentNamed(
'expectedUpdatedEvents',
shouldCheckIntraInvocationId: true,
),
),
_AssertMethod(
className: 'FineAnalysisDriverTest',
methodName: '_runChangeScenarioUA',
argument: _ArgumentNamed(
'expectedUpdatedEvents',
shouldCheckIntraInvocationId: true,
),
),
_AssertMethod(
className: 'FineAnalysisDriverTest',
methodName: '_runChangeScenario',
argument: _ArgumentNamed(
'expectedInitialEvents',
shouldCheckIntraInvocationId: true,
),
),
_AssertMethod(
className: 'FineAnalysisDriverTest',
methodName: '_runChangeScenario',
argument: _ArgumentNamed(
'expectedUpdatedEvents',
shouldCheckIntraInvocationId: true,
),
),
_AssertMethod(
className: 'FineAnalysisDriverTest',
methodName: '_runLibraryManifestScenario',
argument: _ArgumentNamed(
'expectedInitialEvents',
shouldCheckIntraInvocationId: true,
),
),
_AssertMethod(
className: 'FineAnalysisDriverTest',
methodName: '_runLibraryManifestScenario',
argument: _ArgumentNamed(
'expectedUpdatedEvents',
shouldCheckIntraInvocationId: true,
),
),
_AssertMethod(
className: 'FineAnalysisDriverTest',
methodName: '_runManualRequirementsRecording',
argument: _ArgumentNamed('expectedEvents'),
),
_AssertMethod(
className: 'IndexTest',
methodName: 'assertElementIndexText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'IndexTest',
methodName: 'assertLibraryFragmentIndexText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: '_InheritanceManager3Base2',
methodName: 'assertInterfaceText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'LibraryElementTest_scope',
methodName: '_assertLibraryExtensions',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'LibraryFragmentElementTest',
methodName: '_assertScopeLookups',
argument: _ArgumentIndex(3),
),
_AssertMethod(
className: 'MetadataResolutionTest',
methodName: '_assertAnnotationValueText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ParserDiagnosticsTest',
methodName: 'assertParsedNodeText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ResolutionTest',
methodName: 'assertDartObjectText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ResolutionTest',
methodName: 'assertParsedNodeText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ResolutionTest',
methodName: 'assertResolvedLibraryResultText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'ResolutionTest',
methodName: 'assertResolvedNodeText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'SearchTest',
methodName: 'assertDeclarationsText',
argument: _ArgumentIndex(2),
),
_AssertMethod(
className: 'SearchTest',
methodName: 'assertElementReferencesText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'SearchTest',
methodName: 'assertLibraryFragmentReferencesText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'SearchTest',
methodName: 'assertLibraryImportReferencesText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: 'SearchTest',
methodName: 'assertUnresolvedMemberReferencesText',
argument: _ArgumentIndex(1),
),
_AssertMethod(
className: '_EventsMixin',
methodName: 'assertEventsText',
argument: _ArgumentIndex(1),
),
];
/// Some top-level "assert" have multiple expectations.
/// They set this field, so that [_ArgumentNamed] can disambiguate.
static String? intraInvocationId;
static final Map<String, _File> _files = {};
static void add(String actual) {
if (!updatingIsEnabled) {
return;
}
var traceLines = '${StackTrace.current}'.split('\n');
for (var assertMethod in assertMethods) {
// Disambiguate multi-expectation arguments.
if (assertMethod.argument case _ArgumentNamed namedArgument) {
if (namedArgument.shouldCheckIntraInvocationId) {
var id = NodeTextExpectationsCollector.intraInvocationId;
if (namedArgument.name != id) {
continue;
}
}
}
for (var traceIndex = 0; traceIndex < traceLines.length; traceIndex++) {
var traceLine = traceLines[traceIndex];
if (!traceLine.contains(' ${assertMethod.stackTracePattern} ')) {
continue;
}
// Find the invocation of the assert method in the stack trace.
var invocationTraceIndex = traceIndex + 1;
if (traceLines[invocationTraceIndex] == '<asynchronous suspension>') {
invocationTraceIndex++;
}
var invocationTraceLine = traceLines[invocationTraceIndex];
// Parse the invocation stack trace line.
var locationMatch = RegExp(
r'(file://.+_test.dart):(\d+):',
).firstMatch(invocationTraceLine);
if (locationMatch == null) {
fail('Cannot parse: $invocationTraceLine');
}
var path = Uri.parse(locationMatch.group(1)!).toFilePath();
var line = int.parse(locationMatch.group(2)!);
var file = _getFile(path);
var invocation = file.findInvocation(invocationLine: line);
if (invocation == null) {
fail('Cannot find MethodInvocation.');
}
if (invocation.methodName.name != assertMethod.methodName) {
fail(
'Expected: ${assertMethod.methodName}\n'
'Actual: ${invocation.methodName.name}\n',
);
}
var argumentList = invocation.argumentList;
var argument = assertMethod.argument.get(argumentList);
if (argument is! SimpleStringLiteral) {
fail('Not a literal: ${argument.runtimeType}');
}
file.addReplacement(
_Replacement(argument.contentsOffset, argument.contentsEnd, actual),
);
// Stop after the first (most specific) assert method.
return;
}
}
}
static void apply() {
for (var file in _files.values) {
file.applyReplacements();
}
_files.clear();
}
static _File _getFile(String path) {
return _files[path] ??= _File(path);
}
}
@reflectiveTest
class UpdateNodeTextExpectations {
test_applyReplacements() {
NodeTextExpectationsCollector.apply();
}
}
sealed class _Argument {
Expression get(ArgumentList argumentList);
}
final class _ArgumentIndex extends _Argument {
final int index;
_ArgumentIndex(this.index);
@override
Expression get(ArgumentList argumentList) {
return argumentList.arguments.whereNotType<NamedExpression>().elementAt(
index,
);
}
}
final class _ArgumentNamed extends _Argument {
final String name;
final bool shouldCheckIntraInvocationId;
_ArgumentNamed(this.name, {this.shouldCheckIntraInvocationId = false});
@override
Expression get(ArgumentList argumentList) {
return argumentList.arguments
.whereType<NamedExpression>()
.where((argument) => argument.name.label.name == name)
.single
.expression;
}
}
class _AssertMethod {
final String methodName;
final String stackTracePattern;
final _Argument argument;
const _AssertMethod({
required String className,
required this.methodName,
required this.argument,
}) : stackTracePattern = '$className.$methodName';
const _AssertMethod.forFunction({
required this.methodName,
required this.argument,
}) : stackTracePattern = ' $methodName';
}
class _File {
final String path;
final String content;
final LineInfo lineInfo;
final CompilationUnit unit;
final List<_Replacement> replacements = [];
factory _File(String path) {
var content = io.File(path).readAsStringSync();
var collection = AnalysisContextCollection(
resourceProvider: PhysicalResourceProvider.INSTANCE,
includedPaths: [path],
);
var analysisContext = collection.contextFor(path);
var analysisSession = analysisContext.currentSession;
var parseResult = analysisSession.getParsedUnit(path);
parseResult as ParsedUnitResult;
return _File._(
path: path,
content: content,
lineInfo: LineInfo.fromContent(content),
unit: parseResult.unit,
);
}
_File._({
required this.path,
required this.content,
required this.lineInfo,
required this.unit,
});
void addReplacement(_Replacement replacement) {
// Check if there is the same replacement.
for (var existing in replacements) {
if (existing.offset == replacement.offset) {
// Sanity check.
if (existing.end != replacement.end) {
fail(
'At offset: ${existing.offset}\n'
'Existing end: ${existing.end}\n'
'New end: ${replacement.end}\n',
);
}
if (existing.text != replacement.text) {
fail(
'At offset: ${existing.offset}\n'
'Existing text:\n${existing.text}\n'
'New text:\n${replacement.end}\n',
);
}
// We already have the same replacement, exit.
return;
}
}
// This is a new replacement, add it.
replacements.add(replacement);
}
void applyReplacements() {
replacements.sort((a, b) => b.offset - a.offset);
var newCode = content;
for (var replacement in replacements) {
newCode =
newCode.substring(0, replacement.offset) +
replacement.text +
newCode.substring(replacement.end);
}
io.File(path).writeAsStringSync(newCode);
}
MethodInvocation? findInvocation({required int invocationLine}) {
var visitor = _InvocationVisitor(
lineInfo: lineInfo,
requestedLine: invocationLine,
);
unit.accept(visitor);
return visitor.result;
}
}
class _InvocationVisitor extends RecursiveAstVisitor<void> {
final LineInfo lineInfo;
final int requestedLine;
MethodInvocation? result;
_InvocationVisitor({required this.lineInfo, required this.requestedLine});
@override
void visitMethodInvocation(MethodInvocation node) {
if (result != null) {
return;
}
var nodeLine = lineInfo.getLocation(node.offset).lineNumber;
if (nodeLine == requestedLine) {
result = node;
}
super.visitMethodInvocation(node);
}
}
class _Replacement {
final int offset;
final int end;
final String text;
_Replacement(this.offset, this.end, this.text);
}