blob: e339a5e4b8b1a497b5b28515c004ee39acc0d800 [file] [log] [blame]
// Copyright (c) 2019, 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.
// Only needed so that [TestFile] can be referenced in doc comments.
import 'test_file.dart';
/// Describes a static error.
///
/// These can be parsed from comments in [TestFile]s, in which case they
/// represent *expected* errors. If a test contains any of these, then it is a
/// "static error test" and exists to validate that a conforming front end
/// produces the expected compile-time errors.
///
/// Aside from location, there are two interesting attributes of an error that
/// a test can verify: its error code and the error message. Currently, for
/// analyzer we only care about the error code. The CFE does not report an
/// error code and only reports a message. So this class takes advantage of
/// that by allowing you to set expectations for analyzer and CFE independently
/// by assuming the [code] field is only used for the former and the [message]
/// for the latter.
///
/// This same class is also used for *reported* errors when parsing the output
/// of a front end.
class StaticError implements Comparable<StaticError> {
static const _unspecified = "unspecified";
/// Parses the set of static error expectations defined in the Dart source
/// file [source].
static List<StaticError> parseExpectations(String source) =>
_ErrorExpectationParser(source)._parse();
/// Collapses overlapping [errors] into a shorter list of errors where
/// possible.
///
/// Two errors on the same location can be collapsed if one has an error code
/// but no message and the other has a message but no code.
static List<StaticError> simplify(List<StaticError> errors) {
var result = errors.toList();
result.sort();
for (var i = 0; i < result.length - 1; i++) {
var a = result[i];
// Look for a later error we can merge with this one. Usually, it will be
// adjacent to this one, but if there are multiple errors with no length
// on the same location, those will all be next to each other and their
// merge targets will come later. This happens when CFE reports multiple
// errors at the same location (messages but no length) and analyzer does
// too (codes and lengths but no messages).
for (var j = i + 1; j < result.length; j++) {
var b = result[j];
// Position must be the same. If the position is different, we can
// stop looking because all same-position errors will be adjacent.
if (a.line != b.line) break;
if (a.column != b.column) break;
// If they both have lengths that are different, we can't discard that
// information.
if (a.length != null && b.length != null && a.length != b.length) {
continue;
}
// Can't discard content.
if (a.code != null && b.code != null) continue;
if (a.message != null && b.message != null) continue;
result[i] = StaticError(
line: a.line,
column: a.column,
length: a.length ?? b.length,
code: a.code ?? b.code,
message: a.message ?? b.message);
result.removeAt(j);
break;
}
}
return result;
}
/// Determines whether all [actualErrors] match the given [expectedErrors].
///
/// If they match, returns `null`. Otherwise returns a string describing the
/// mismatches. This for a human-friendly explanation of the difference
/// between the two sets of errors, while also being simple to implement.
/// An expected error that is completely identical to an actual error is
/// treated as a match. Everything else is a failure.
///
/// It treats line number as the "identity" of an error. So if there are two
/// errors on the same line that differ in other properties, it reports that
/// as a "wrong" error. Any expected error on a line containing no actual
/// error is reported as a "missing" error. Conversely, an actual error on a
/// line containing no expected error is an "unexpected" error.
///
/// By not treating the error's index in the list to be its identity, we
/// gracefully handle extra or missing errors without causing cascading
/// failures for later errors in the lists.
static String validateExpectations(Iterable<StaticError> expectedErrors,
Iterable<StaticError> actualErrors) {
// Don't require the test or front end to output in any specific order.
var sortedExpected = expectedErrors.toList();
var sortedActual = actualErrors.toList();
sortedExpected.sort();
sortedActual.sort();
var buffer = StringBuffer();
describeError(String adjective, StaticError error, String verb) {
buffer.writeln("$adjective static error at ${error.location}:");
if (error.code == _unspecified) {
buffer.writeln("- $verb unspecified error code.");
} else if (error.code != null) {
buffer.writeln("- $verb error code ${error.code}.");
}
if (error.message == _unspecified) {
buffer.writeln("- $verb unspecified error message.");
} else if (error.message != null) {
buffer.writeln("- $verb error message '${error.message}'.");
}
buffer.writeln();
}
var expectedIndex = 0;
var actualIndex = 0;
for (;
expectedIndex < sortedExpected.length &&
actualIndex < sortedActual.length;) {
var expected = sortedExpected[expectedIndex];
var actual = sortedActual[actualIndex];
var differences = expected.describeDifferences(actual);
if (differences == null) {
expectedIndex++;
actualIndex++;
continue;
}
if (expected.line == actual.line) {
buffer.writeln("Wrong static error at ${expected.location}:");
for (var difference in differences) {
buffer.writeln("- $difference");
}
buffer.writeln();
expectedIndex++;
actualIndex++;
} else if (expected.line < actual.line) {
describeError("Missing", expected, "Expected");
expectedIndex++;
} else {
describeError("Unexpected", actual, "Had");
actualIndex++;
}
}
// Output any trailing expected or actual errors if the lengths of the
// lists differ.
for (; expectedIndex < sortedExpected.length; expectedIndex++) {
describeError("Missing", sortedExpected[expectedIndex], "Expected");
}
for (; actualIndex < sortedActual.length; actualIndex++) {
describeError("Unexpected", sortedActual[actualIndex], "Had");
}
if (buffer.isEmpty) return null;
return buffer.toString().trimRight();
}
/// The one-based line number of the beginning of the error's location.
final int line;
/// The one-based column number of the beginning of the error's location.
final int column;
/// The number of characters in the error location.
///
/// This is optional. The CFE only reports error location, but not length.
final int length;
/// The expected analyzer error code for the error or `null` if this error
/// isn't expected to be reported by analyzer.
final String code;
/// The expected CFE error message or `null` if this error isn't expected to
/// be reported by the CFE.
final String message;
/// The zero-based index of the first line in the [TestFile] containing the
/// marker comments that define this error.
///
/// If this error was not parsed from a file, this may be `null`.
final int markerStartLine;
/// The zero-based index of the last line in the [TestFile] containing the
/// marker comments that define this error, inclusive.
///
/// If this error was not parsed from a file, this may be `null`.
final int markerEndLine;
/// Creates a new StaticError at the given location with the given expected
/// error code and message.
///
/// In order to make it easier to incrementally add error tests before a
/// feature is fully implemented or specified, an error expectation can be in
/// an "unspecified" state for either or both platforms by having the error
/// code or message be the special string "unspecified". When an unspecified
/// error is tested, a front end is expected to report *some* error on that
/// error's line, but it can be any location, error code, or message.
StaticError(
{this.line,
this.column,
this.length,
this.code,
this.message,
this.markerStartLine,
this.markerEndLine}) {
// Must have a location.
assert(line != null);
assert(column != null);
// Must have at least one piece of description.
assert(code != null || message != null);
}
/// Whether this error should be reported by analyzer.
bool get isAnalyzer => code != null;
/// Whether this error should be reported by the CFE.
bool get isCfe => message != null;
/// A textual description of this error's location.
String get location {
var result = "line $line, column $column";
if (length != null) result += ", length $length";
return result;
}
String toString() {
var result = "Error at $location";
if (code != null) result += "\n$code";
if (message != null) result += "\n$message";
return result;
}
/// Orders errors primarily by location, then by other fields if needed.
@override
int compareTo(StaticError other) {
if (line != other.line) return line.compareTo(other.line);
if (column != other.column) return column.compareTo(other.column);
// Sort no length after all other lengths.
if (length == null && other.length != null) return 1;
if (length != null && other.length == null) return -1;
if (length != other.length) return length.compareTo(other.length);
var thisCode = code ?? "";
var otherCode = other.code ?? "";
if (thisCode != otherCode) return thisCode.compareTo(otherCode);
var thisMessage = message ?? "";
var otherMessage = other.message ?? "";
return thisMessage.compareTo(otherMessage);
}
/// Compares this error expectation to [actual].
///
/// If this error correctly matches [actual], returns `null`. Otherwise
/// returns a list of strings describing the mismatch.
///
/// Note that this does *not* check to see that [actual] matches the platforms
/// that this error expects. For example, if [actual] only reports an error
/// code (i.e. it is analyzer-only) and this error only specifies an error
/// message (i.e. it is CFE-only), this will still report differences in
/// location information. This method expects that error expectations have
/// already been filtered by platform so this will only be called in cases
/// where the platforms do match.
List<String> describeDifferences(StaticError actual) {
var differences = <String>[];
if (line != actual.line) {
differences.add("Expected on line $line but was on ${actual.line}.");
}
// If the error is unspecified on the front end being tested, the column
// and length can be any values.
var requirePreciseLocation = code != _unspecified && actual.isAnalyzer ||
message != _unspecified && actual.isCfe;
if (requirePreciseLocation) {
if (column != actual.column) {
differences
.add("Expected on column $column but was on ${actual.column}.");
}
// This error represents an expectation, so should have a length.
assert(length != null);
if (actual.length != null && length != actual.length) {
differences.add("Expected length $length but was ${actual.length}.");
}
}
if (code != null &&
code != _unspecified &&
actual.code != null &&
code != actual.code) {
differences.add("Expected error code $code but was ${actual.code}.");
}
if (message != null &&
message != _unspecified &&
actual.message != null &&
message != actual.message) {
differences.add(
"Expected error message '$message' but was '${actual.message}'.");
}
if (differences.isNotEmpty) return differences;
return null;
}
}
class _ErrorExpectationParser {
/// Marks the location of an expected error, like so:
///
/// int i = "s";
/// // ^^^
///
/// We look for a line that starts with a line comment followed by spaces and
/// carets.
static final _caretLocationRegExp = RegExp(r"^\s*//\s*(\^+)\s*$");
/// Matches an explicit error location with a length, like:
///
/// // [error line 1, column 17, length 3]
static final _explicitLocationAndLengthRegExp =
RegExp(r"^\s*//\s*\[\s*error line\s+(\d+)\s*,\s*column\s+(\d+)\s*,\s*"
r"length\s+(\d+)\s*\]\s*$");
/// Matches an explicit error location without a length, like:
///
/// // [error line 1, column 17]
static final _explicitLocationRegExp =
RegExp(r"^\s*//\s*\[\s*error line\s+(\d+)\s*,\s*column\s+(\d+)\s*\]\s*$");
/// An analyzer error expectation starts with `// [analyzer]`.
static final _analyzerErrorRegExp = RegExp(r"^\s*// \[analyzer\]\s*(.*)");
/// An analyzer error code is a dotted identifier or the magic string
/// "unspecified".
static final _errorCodeRegExp = RegExp(r"^\w+\.\w+|unspecified$");
/// The first line of a CFE error expectation starts with `// [cfe]`.
static final _cfeErrorRegExp = RegExp(r"^\s*// \[cfe\]\s*(.*)");
/// Any line-comment-only lines after the first line of a CFE error message
/// are part of it.
static final _errorMessageRestRegExp = RegExp(r"^\s*//\s*(.*)");
/// Matches the multitest marker and yields the preceding content.
final _stripMultitestRegExp = RegExp(r"(.*)//#");
final List<String> _lines;
final List<StaticError> _errors = [];
int _currentLine = 0;
// One-based index of the last line that wasn't part of an error expectation.
int _lastRealLine = -1;
_ErrorExpectationParser(String source) : _lines = source.split("\n");
List<StaticError> _parse() {
while (!_isAtEnd) {
var sourceLine = _peek(0);
var match = _caretLocationRegExp.firstMatch(sourceLine);
if (match != null) {
if (_lastRealLine == -1) {
_fail("An error expectation must follow some code.");
}
_parseErrorDetails(
line: _lastRealLine,
column: sourceLine.indexOf("^") + 1,
length: match.group(1).length);
_advance();
continue;
}
match = _explicitLocationAndLengthRegExp.firstMatch(sourceLine);
if (match != null) {
_parseErrorDetails(
line: int.parse(match.group(1)),
column: int.parse(match.group(2)),
length: int.parse(match.group(3)));
_advance();
continue;
}
match = _explicitLocationRegExp.firstMatch(sourceLine);
if (match != null) {
_parseErrorDetails(
line: int.parse(match.group(1)), column: int.parse(match.group(2)));
_advance();
continue;
}
_lastRealLine = _currentLine + 1;
_advance();
}
return _errors;
}
/// Finishes parsing an error expectation after parsing the location.
void _parseErrorDetails({int line, int column, int length}) {
String code;
String message;
var startLine = _currentLine;
// Look for an error code line.
if (!_isAtEnd) {
var match = _analyzerErrorRegExp.firstMatch(_peek(1));
if (match != null) {
code = match.group(1);
if (!_errorCodeRegExp.hasMatch(code)) {
_fail("An analyzer error expectation should be a dotted identifier.");
}
_advance();
}
}
// Look for an error message.
if (!_isAtEnd) {
var match = _cfeErrorRegExp.firstMatch(_peek(1));
if (match != null) {
message = match.group(1);
_advance();
// Consume as many additional error message lines as we find.
while (!_isAtEnd) {
var nextLine = _peek(1);
// A location line shouldn't be treated as a message.
if (_caretLocationRegExp.hasMatch(nextLine)) break;
if (_explicitLocationAndLengthRegExp.hasMatch(nextLine)) break;
if (_explicitLocationRegExp.hasMatch(nextLine)) break;
// Don't let users arbitrarily order the error code and message.
if (_analyzerErrorRegExp.hasMatch(nextLine)) {
_fail("An analyzer expectation must come before a CFE "
"expectation.");
}
var messageMatch = _errorMessageRestRegExp.firstMatch(nextLine);
if (messageMatch == null) break;
message += "\n" + messageMatch.group(1);
_advance();
}
}
}
if (code == null && message == null) {
_fail("An error expectation must specify at least an analyzer or CFE "
"error.");
}
// Hack: If the error is CFE-only and the length is one, treat it as no
// length. The CFE does not output length information, and when the update
// tool writes a CFE-only error, it implicitly uses a length of one. Thus,
// when we parse back in a length one CFE error, we ignore the length so
// that the error round-trips correctly.
// TODO(rnystrom): Stop doing this when the CFE reports error lengths.
if (code == null && length == 1) length = null;
_errors.add(StaticError(
line: line,
column: column,
length: length,
code: code,
message: message,
markerStartLine: startLine,
markerEndLine: _currentLine));
}
bool get _isAtEnd => _currentLine >= _lines.length;
void _advance() {
_currentLine++;
}
String _peek(int offset) {
var line = _lines[_currentLine + offset];
// Strip off any multitest marker.
var multitestMatch = _stripMultitestRegExp.firstMatch(line);
if (multitestMatch != null) {
line = multitestMatch.group(1).trimRight();
}
return line;
}
void _fail(String message) {
throw FormatException("Test error on line ${_currentLine + 1}: $message");
}
}