b9795ffabb
For the migration script, I need a (less hacky) way of programmatically modifying a status file. As a step along that path, I need the ability to write a status file to disc given a StatusFile object. With that, it was a small step to write a little "formatter" that turns a status file into its canonical representation. It removes unneeded parentheses in expressions, normalizes expectation case, and normalizes whitespace. Seems useful. R=whesse@google.com Review-Url: https://codereview.chromium.org/2988383002 .
310 lines
9.0 KiB
Dart
310 lines
9.0 KiB
Dart
// 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 'src/expression.dart';
|
|
|
|
/// Matches the header that begins a new section, like:
|
|
///
|
|
/// [ $compiler == dart2js && $minified ]
|
|
final _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 _entryPattern = new RegExp(r"^([^:#]+):(.*)");
|
|
|
|
/// Matches an issue number in a comment, like:
|
|
///
|
|
/// blah_test: Fail # Issue 1234
|
|
/// ^^^^
|
|
final _issuePattern = new RegExp(r"[Ii]ssue (\d+)");
|
|
|
|
/// 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 may point to an individual
|
|
/// file, or a directory, in which case it applies to all files under that path.
|
|
///
|
|
/// Entries may also appear before any section header, in which case they
|
|
/// always apply.
|
|
class StatusFile {
|
|
final String path;
|
|
final List<StatusSection> sections = [];
|
|
final List<String> _comments = [];
|
|
|
|
int _lineCount = 0;
|
|
|
|
StatusFile(this.path);
|
|
|
|
/// Parses the status file at [path].
|
|
///
|
|
/// Throws a [SyntaxError] if the file could not be parsed.
|
|
StatusFile.read(this.path) {
|
|
var lines = new File(path).readAsLinesSync();
|
|
_comments.length = lines.length + 1;
|
|
|
|
// The current section whose rules are being parsed.
|
|
StatusSection section;
|
|
|
|
for (var line in lines) {
|
|
_lineCount++;
|
|
|
|
fail(String message, [List<String> errors]) {
|
|
throw new SyntaxError(_shortPath, _lineCount, line, message, errors);
|
|
}
|
|
|
|
// Strip off the comment and whitespace.
|
|
var source = line;
|
|
var comment = "";
|
|
var hashIndex = line.indexOf('#');
|
|
if (hashIndex >= 0) {
|
|
source = line.substring(0, hashIndex);
|
|
comment = line.substring(hashIndex + 1);
|
|
_comments[_lineCount] = comment;
|
|
}
|
|
source = source.trim();
|
|
|
|
// Ignore empty (or comment-only) lines.
|
|
if (source.isEmpty) continue;
|
|
|
|
// See if we are starting a new section.
|
|
var match = _sectionPattern.firstMatch(source);
|
|
if (match != null) {
|
|
try {
|
|
var condition = Expression.parse(match[1].trim());
|
|
section = new StatusSection(condition, _lineCount);
|
|
sections.add(section);
|
|
} on FormatException {
|
|
fail("Status expression syntax error");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Otherwise, it should be a new entry under the current section.
|
|
match = _entryPattern.firstMatch(source);
|
|
if (match != null) {
|
|
var path = match[1].trim();
|
|
// TODO(whesse): Handle test names ending in a wildcard (*).
|
|
var expectations = <Expectation>[];
|
|
for (var name in match[2].split(",")) {
|
|
name = name.trim();
|
|
try {
|
|
expectations.add(Expectation.find(name));
|
|
} on ArgumentError {
|
|
fail('Unrecognized test expectation "$name"');
|
|
}
|
|
}
|
|
|
|
var issue = _issueNumber(comment);
|
|
|
|
// If we haven't found a section header yet, create an implicit section
|
|
// that matches everything.
|
|
if (section == null) {
|
|
section = new StatusSection(null, -1);
|
|
sections.add(section);
|
|
}
|
|
|
|
section.entries
|
|
.add(new StatusEntry(path, _lineCount, expectations, issue));
|
|
continue;
|
|
}
|
|
|
|
fail("Unrecognized input");
|
|
}
|
|
}
|
|
|
|
bool get isEmpty => sections.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) {
|
|
// TODO(rnystrom): It would be more useful if it reported all of the errors
|
|
// instead of stopping on the first.
|
|
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.join(p.dirname(p.fromUri(Platform.script)), "../../../");
|
|
return p.normalize(p.relative(path, from: repoRoot));
|
|
}
|
|
|
|
/// 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]);
|
|
}
|
|
|
|
String toString() {
|
|
var buffer = new StringBuffer();
|
|
for (var section in sections) {
|
|
buffer.writeln("[ ${section.condition} ]");
|
|
|
|
for (var entry in section.entries) {
|
|
buffer.write("${entry.path}: ${entry.expectations.join(', ')}");
|
|
if (entry.issue != null) buffer.write(" # Issue ${entry.issue}");
|
|
buffer.writeln();
|
|
}
|
|
|
|
buffer.writeln();
|
|
}
|
|
|
|
return buffer.toString();
|
|
}
|
|
|
|
/// Serialize the status file to a string.
|
|
///
|
|
/// Unlike [toString()], this preserves comments and gives a "canonical"
|
|
/// rendering of the status file that can be saved back to disc.
|
|
String serialize() {
|
|
var buffer = new StringBuffer();
|
|
|
|
var lastLine = 0;
|
|
var needBlankLine = false;
|
|
|
|
void writeLine(String text, int line) {
|
|
var comment = _comments[line];
|
|
if (text == null && comment == null) {
|
|
// There's no comment on this line, so it's blank.
|
|
needBlankLine = true;
|
|
return;
|
|
}
|
|
|
|
if (needBlankLine) buffer.writeln();
|
|
needBlankLine = false;
|
|
|
|
if (text != null) {
|
|
buffer.write(text);
|
|
}
|
|
|
|
if (comment != null) {
|
|
if (text != null) buffer.write(" ");
|
|
buffer.write("#$comment");
|
|
}
|
|
|
|
buffer.writeln();
|
|
}
|
|
|
|
void writeText(String text, int line) {
|
|
if (line != null) {
|
|
while (++lastLine < line) {
|
|
writeLine(null, lastLine);
|
|
}
|
|
}
|
|
|
|
writeLine(text, line);
|
|
}
|
|
|
|
for (var section in sections) {
|
|
if (section.condition != null) {
|
|
writeText("[ ${section.condition} ]", section.lineNumber);
|
|
}
|
|
|
|
for (var entry in section.entries) {
|
|
writeText("${entry.path}: ${entry.expectations.join(', ')}",
|
|
entry.lineNumber);
|
|
}
|
|
|
|
needBlankLine = true;
|
|
}
|
|
|
|
// Write any trailing comments.
|
|
while (++lastLine <= _lineCount) {
|
|
writeLine(null, lastLine);
|
|
}
|
|
|
|
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 entry appears in the file.
|
|
final int lineNumber;
|
|
|
|
final List<StatusEntry> entries = [];
|
|
|
|
/// Returns true if this section should apply in the given [environment].
|
|
bool isEnabled(Environment environment) =>
|
|
condition == null || condition.evaluate(environment);
|
|
|
|
StatusSection(this.condition, this.lineNumber);
|
|
}
|
|
|
|
/// Describes the test status of the file or files at a given path.
|
|
class StatusEntry {
|
|
final String path;
|
|
|
|
/// The one-based line number where the entry appears in the file.
|
|
final int lineNumber;
|
|
|
|
final List<Expectation> expectations;
|
|
final int issue;
|
|
|
|
StatusEntry(this.path, this.lineNumber, this.expectations, this.issue);
|
|
}
|
|
|
|
/// Error thrown when a parse or validation error occurs in a [StatusFile].
|
|
class SyntaxError implements Exception {
|
|
final String file;
|
|
final int lineNumber;
|
|
final String line;
|
|
final String message;
|
|
final List<String> errors;
|
|
|
|
SyntaxError(this.file, this.lineNumber, this.line, this.message, this.errors);
|
|
|
|
String toString() {
|
|
var buffer = new StringBuffer();
|
|
buffer.writeln('$message in "$file" line $lineNumber:');
|
|
buffer.writeln(line);
|
|
|
|
if (errors != null) {
|
|
for (var error in errors) {
|
|
buffer.writeln("- ${error.replaceAll('\n', '\n ')}");
|
|
}
|
|
}
|
|
|
|
return buffer.toString().trimRight();
|
|
}
|
|
}
|