blob: 6f7f134a89e5a908074fa666fbcf60b6e2db8b24 [file] [log] [blame]
// Copyright (c) 2017, 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';
import 'package:path/path.dart' as p;
import 'environment.dart';
import 'expectation.dart';
import 'status_file.dart';
import 'src/expression.dart';
/// Matches the header that begins a new section, like:
///
/// [ $compiler == dart2js && $minified ]
final RegExp _sectionPattern = new RegExp(r"^\[(.+?)\]");
/// Matches an entry that defines the status for a path in the current section,
/// like:
///
/// some/path/to/some_test: Pass || Fail
final RegExp _entryPattern = new RegExp(r"^([^:#]+):([^#]+)(#.*)?");
/// Matches an issue number in a comment, like:
///
/// blah_test: Fail # Issue 1234
/// ^^^^
final RegExp _issuePattern = new RegExp(r"[Ii]ssue (\d+)");
/// Matches a comment and indented comment, like:
///
/// < white space > #
final RegExp _commentPattern = new RegExp(r"^(\s*)#");
/// A parsed status file, which describes how a collection of tests are
/// expected to behave under various configurations and conditions.
///
/// Each status file is made of a series of sections. Each section begins with
/// a header, followed by a series of entries. A header is enclosed in square
/// brackets and contains a Boolean expression. That expression is evaluated in
/// an environment. If it evaluates to true, then the entries after the header
/// take effect.
///
/// Each entry is a glob-like file path followed by a colon and then a
/// comma-separated list of [Expectation]s. The path is a regular expression
/// which may match one or more file or directory paths. If it matches a
/// directory path, it is considered to match all files in that directory or
/// (recursively) its subdirectories.
///
/// The intent is that status files will not have stand-alone comment lines. All
/// comments should be at the end of a single entry, and apply to that entry
/// only.
///
/// Until this is true for all status files, this program handles stand-alone
/// comment lines as follows:
///
/// 1) One or more comment lines immediately preceding a section header if there
/// is a linebreak from the previous section. Comment sections are added
/// directly to a section and are not entries.
/// 2) Comment lines anywhere else. These should be carefully removed when
/// found.
///
/// The reason for this distinction is to allow comments to be above sections,
/// without including these when lexicographically ordering section entries.
///
/// Entries may also appear before any section header, are considered to belong
/// to a default section, which always applies.
class StatusFile {
final String path;
final List<StatusSection> sections = [];
int _lineCount = 1;
/// Constructor for creating a new [StatusFile]. Will not create the default
/// section that status files have.
StatusFile(this.path);
/// Reads and parses the status file at [path].
///
/// Throws a [SyntaxError] if the file could not be parsed.
StatusFile.read(this.path) {
_parse(new File(path).readAsLinesSync());
}
/// Parses lines of strings coming from a status file at [path].
///
/// Throws a [SyntaxError] if the file could not be parsed.
StatusFile.parse(this.path, List<String> lines) {
_parse(lines.map((line) => line.trim()).toList());
}
void _parse(List<String> lines) {
// We define a few helper functions that are used when parsing.
/// Checks if [currentLine] is a comment and returns the first regular
/// expression match, or null otherwise.
Match commentEntryMatch(int currentLine) {
if (currentLine < 1 || currentLine > lines.length) {
return null;
}
return _commentPattern.firstMatch(lines[currentLine - 1]);
}
/// Finds a section header on [currentLine] if the line is in range of
/// [lines].
Match sectionHeaderMatch(int currentLine) {
if (currentLine < 1 || currentLine > lines.length) {
return null;
}
return _sectionPattern.firstMatch(lines[currentLine - 1]);
}
/// Checks if a line has a break from the previous section. A break is an
/// empty line. It searches recursively until it find a break or a test
/// entry.
bool hasBreakFromPreviousSection(int currentLine) {
if (currentLine <= 1) {
return true;
}
var line = lines[currentLine - 1];
if (line.isEmpty) {
return true;
}
if (line.startsWith("#")) {
return hasBreakFromPreviousSection(currentLine - 1);
}
return false;
}
/// Checks if comment on [currentLine] belongs to the next section.
bool commentBelongsToNextSectionHeader(int currentLine) {
if (currentLine >= lines.length ||
commentEntryMatch(currentLine) == null) {
return false;
}
return sectionHeaderMatch(currentLine + 1) != null ||
commentBelongsToNextSectionHeader(currentLine + 1);
}
// List of comments added before the next section's header.
List<Entry> sectionHeaderComments = [];
// Parse file comments
var lastEmptyLine = 0;
for (; _lineCount <= lines.length; _lineCount++) {
var line = lines[_lineCount - 1];
if (!line.startsWith("#") && line.isNotEmpty) {
break;
}
if (line.isEmpty) {
sectionHeaderComments.add(new EmptyEntry(_lineCount));
lastEmptyLine = _lineCount;
} else {
sectionHeaderComments
.add(new CommentEntry(_lineCount, new Comment(line)));
}
}
var implicitSectionHeaderComments = sectionHeaderComments;
var entries = <Entry>[];
if (lastEmptyLine > 0 && sectionHeaderMatch(_lineCount) != null) {
// Comments after the last empty line belong to the next section's header.
// The empty line is not added to the section header, because it will be
// added to the section's entries.
implicitSectionHeaderComments =
implicitSectionHeaderComments.sublist(0, lastEmptyLine - 1);
entries.add(sectionHeaderComments[lastEmptyLine - 1]);
sectionHeaderComments = sectionHeaderComments.sublist(lastEmptyLine);
} else {
// Reset section header comments.
sectionHeaderComments = [];
}
// The current section whose rules are being parsed. Initialized to an
// implicit section that matches everything.
StatusSection section =
new StatusSection(null, -1, implicitSectionHeaderComments);
section.entries.addAll(entries);
sections.add(section);
for (; _lineCount <= lines.length; _lineCount++) {
var line = lines[_lineCount - 1];
fail(String message, [List<String> errors]) {
throw new SyntaxError(_shortPath, _lineCount, line, message, errors);
}
// If it is an empty line
if (line.isEmpty) {
section.entries.add(new EmptyEntry(_lineCount));
continue;
}
// See if we are starting a new section.
var match = _sectionPattern.firstMatch(line);
if (match != null) {
try {
var condition = Expression.parse(match[1].trim());
section =
new StatusSection(condition, _lineCount, sectionHeaderComments);
sections.add(section);
// Reset section header comments.
sectionHeaderComments = [];
} on FormatException {
fail("Status expression syntax error");
}
continue;
}
// If it is in a new entry we should add to the current section.
match = _entryPattern.firstMatch(line);
if (match != null) {
var path = match[1].trim();
var expectations = <Expectation>[];
// split expectations
match[2].split(",").forEach((name) {
try {
expectations.add(Expectation.find(name.trim()));
} on ArgumentError {
fail('Unrecognized test expectation "${name.trim()}"');
}
});
if (match[3] == null) {
section.entries
.add(new StatusEntry(path, _lineCount, expectations, null));
} else {
section.entries.add(new StatusEntry(
path, _lineCount, expectations, new Comment(match[3])));
}
continue;
}
// If it is a comment, we have to find if it belongs with the current
// section or the next section
match = _commentPattern.firstMatch(line);
if (match != null) {
var commentEntry = new CommentEntry(_lineCount, new Comment(line));
if (hasBreakFromPreviousSection(_lineCount) &&
commentBelongsToNextSectionHeader(_lineCount)) {
sectionHeaderComments.add(commentEntry);
} else {
section.entries.add(commentEntry);
}
continue;
}
fail("Unrecognized input");
}
// There are no comment entries in [sectionHeaderComments], because of the
// check for [commentBelongsToSectionHeader].
assert(sectionHeaderComments.length == 0);
}
bool get isEmpty => sections.length == 1 && sections[0].isEmpty();
/// Validates that the variables and values used in all of the section
/// condition expressions are defined in [environment].
///
/// Throws a [SyntaxError] on the first found error.
void validate(Environment environment) {
for (var section in sections) {
if (section.condition == null) continue;
var errors = <String>[];
section.condition.validate(environment, errors);
if (errors.isNotEmpty) {
var s = errors.length > 1 ? "s" : "";
throw new SyntaxError(_shortPath, section.lineNumber,
"[ ${section.condition} ]", 'Validation error$s', errors);
}
}
}
/// Gets the path to this status file relative to the Dart repo root.
String get _shortPath {
var repoRoot = p.fromUri(Platform.script.resolve('../../../'));
return p.normalize(p.relative(path, from: repoRoot));
}
/// Returns the status file as a string. This preserves comments and gives a
/// "canonical" rendering of the status file that can be saved back to disc.
String toString() {
var buffer = new StringBuffer();
sections.forEach(buffer.write);
return buffer.toString();
}
}
/// One section in a status file.
///
/// Contains the condition from the header that begins the section, then all of
/// the entries within the section.
class StatusSection {
/// The expression that determines when this section is applied.
///
/// May be `null` for paths that appear before any section header in the file.
/// In that case, the section always applies.
final Expression condition;
/// The one-based line number where the section appears in the file.
final int lineNumber;
/// Collection of all comment and status line entries.
final List<Entry> entries = [];
final List<Entry> sectionHeaderComments;
/// Returns true if this section should apply in the given [environment].
bool isEnabled(Environment environment) =>
condition == null || condition.evaluate(environment);
bool isEmpty() => !entries.any((entry) => entry is StatusEntry);
StatusSection(this.condition, this.lineNumber, this.sectionHeaderComments);
@override
String toString() {
var buffer = new StringBuffer();
sectionHeaderComments.forEach(buffer.writeln);
if (condition != null) {
buffer.writeln("[ ${condition} ]");
}
entries.forEach(buffer.writeln);
return buffer.toString();
}
}
class Comment {
final String _comment;
Comment(this._comment);
/// Returns the issue number embedded in [comment] or `null` if there is none.
int issueNumber(String comment) {
var match = _issuePattern.firstMatch(comment);
if (match == null) return null;
return int.parse(match[1]);
}
@override
String toString() {
return _comment;
}
}
abstract class Entry {
/// The one-based line number where the entry appears in the file.
final int lineNumber;
Entry(this.lineNumber);
}
class EmptyEntry extends Entry {
EmptyEntry(lineNumber) : super(lineNumber);
@override
String toString() {
return "";
}
}
class CommentEntry extends Entry {
final Comment comment;
CommentEntry(lineNumber, this.comment) : super(lineNumber);
@override
String toString() {
return comment.toString();
}
}
/// Describes the test status of the file or files at a given path.
class StatusEntry extends Entry {
final String path;
final List<Expectation> expectations;
final Comment comment;
StatusEntry(this.path, lineNumber, this.expectations, this.comment)
: super(lineNumber);
@override
String toString() {
return comment == null
? "$path: ${expectations.join(', ')}"
: "$path: ${expectations.join(', ')} $comment";
}
}