From 230cfc209cdb0302b18a3e16127295da4d6b0329 Mon Sep 17 00:00:00 2001
From: Natalie Weizenbaum <nweiz@google.com>
Date: Mon, 20 Apr 2015 17:38:29 -0700
Subject: [PATCH] Refactor parseMetadata.

This moves most of the functionality into reusable helper methods, to make it
easier to add new metadata in the future.

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//1093313002
---
 lib/src/runner/parse_metadata.dart | 527 +++++++++++++++--------------
 1 file changed, 274 insertions(+), 253 deletions(-)

diff --git a/lib/src/runner/parse_metadata.dart b/lib/src/runner/parse_metadata.dart
index 21ed6e2c..e4e40796 100644
--- a/lib/src/runner/parse_metadata.dart
+++ b/lib/src/runner/parse_metadata.dart
@@ -15,310 +15,331 @@ import '../backend/metadata.dart';
 import '../frontend/timeout.dart';
 import '../util/dart.dart';
 
-/// The valid argument names for [new Duration].
-const _durationArgs = const [
-  "days",
-  "hours",
-  "minutes",
-  "seconds",
-  "milliseconds",
-  "microseconds"
-];
-
 /// Parse the test metadata for the test file at [path].
 ///
 /// Throws an [AnalysisError] if parsing fails or a [FormatException] if the
 /// test annotations are incorrect.
-Metadata parseMetadata(String path) {
-  var timeout;
-  var testOn;
-  var skip;
-
-  var contents = new File(path).readAsStringSync();
-  var directives = parseDirectives(contents, name: path).directives;
-  var annotations = directives.isEmpty ? [] : directives.first.metadata;
-
-  // We explicitly *don't* just look for "package:test" imports here,
-  // because it could be re-exported from another library.
-  var prefixes = directives.map((directive) {
-    if (directive is! ImportDirective) return null;
-    if (directive.prefix == null) return null;
-    return directive.prefix.name;
-  }).where((prefix) => prefix != null).toSet();
-
-  for (var annotation in annotations) {
-    // The annotation syntax is ambiguous between named constructors and
-    // prefixed annotations, so we need to resolve that ambiguity using the
-    // known prefixes. The analyzer parses "@x.y()" as prefix "x", annotation
-    // "y", and named constructor null. It parses "@x.y.z()" as prefix "x",
-    // annotation "y", and named constructor "z".
-    var name;
-    var constructorName;
-    var identifier = annotation.name;
-    if (identifier is PrefixedIdentifier &&
-        !prefixes.contains(identifier.prefix.name) &&
-        annotation.constructorName == null) {
-      name = identifier.prefix.name;
-      constructorName = identifier.identifier.name;
-    } else {
-      name = identifier is PrefixedIdentifier
-          ? identifier.identifier.name
-          : identifier.name;
-      if (annotation.constructorName != null) {
-        constructorName = annotation.constructorName.name;
-      }
-    }
+Metadata parseMetadata(String path) => new _Parser(path).parse();
+
+/// A parser for test suite metadata.
+class _Parser {
+  /// The path to the test suite.
+  final String _path;
+
+  /// All annotations at the top of the file.
+  List<Annotation> _annotations;
+
+  /// All prefixes defined by imports in this file.
+  Set<String> _prefixes;
+
+  _Parser(String path)
+      : _path = path {
+    var contents = new File(path).readAsStringSync();
+    var directives = parseDirectives(contents, name: path).directives;
+    _annotations = directives.isEmpty ? [] : directives.first.metadata;
+
+    // We explicitly *don't* just look for "package:test" imports here,
+    // because it could be re-exported from another library.
+    _prefixes = directives.map((directive) {
+      if (directive is! ImportDirective) return null;
+      if (directive.prefix == null) return null;
+      return directive.prefix.name;
+    }).where((prefix) => prefix != null).toSet();
+  }
 
-    if (name == 'TestOn') {
-      if (testOn != null) {
-        throw new SourceSpanFormatException(
-            "Only a single TestOn annotation may be used for a given test file.",
-            _spanFor(annotation, path));
+  /// Parses the metadata.
+  Metadata parse() {
+    var timeout;
+    var testOn;
+    var skip;
+
+    for (var annotation in _annotations) {
+      // The annotation syntax is ambiguous between named constructors and
+      // prefixed annotations, so we need to resolve that ambiguity using the
+      // known prefixes. The analyzer parses "@x.y()" as prefix "x", annotation
+      // "y", and named constructor null. It parses "@x.y.z()" as prefix "x",
+      // annotation "y", and named constructor "z".
+      var name;
+      var constructorName;
+      var identifier = annotation.name;
+      if (identifier is PrefixedIdentifier &&
+          !_prefixes.contains(identifier.prefix.name) &&
+          annotation.constructorName == null) {
+        name = identifier.prefix.name;
+        constructorName = identifier.identifier.name;
+      } else {
+        name = identifier is PrefixedIdentifier
+            ? identifier.identifier.name
+            : identifier.name;
+        if (annotation.constructorName != null) {
+          constructorName = annotation.constructorName.name;
+        }
       }
-      testOn = _parseTestOn(annotation, constructorName, path);
-    } else if (name == 'Timeout') {
-      if (timeout != null) {
-        throw new SourceSpanFormatException(
-            "Only a single Timeout annotation may be used for a given test file.",
-            _spanFor(annotation, path));
-      }
-      timeout = _parseTimeout(annotation, constructorName, path);
-    } else if (name == 'Skip') {
-      if (skip != null) {
-        throw new SourceSpanFormatException(
-            "Only a single Skip annotation may be used for a given test file.",
-            _spanFor(annotation, path));
+
+      if (name == 'TestOn') {
+        _assertSingleAnnotation(testOn, 'TestOn', annotation);
+        testOn = _parseTestOn(annotation, constructorName);
+      } else if (name == 'Timeout') {
+        _assertSingleAnnotation(timeout, 'Timeout', annotation);
+        timeout = _parseTimeout(annotation, constructorName);
+      } else if (name == 'Skip') {
+        _assertSingleAnnotation(skip, 'Skip', annotation);
+        skip = _parseSkip(annotation, constructorName);
       }
-      skip = _parseSkip(annotation, constructorName, path);
     }
-  }
 
-  try {
-    return new Metadata.parse(
-        testOn: testOn == null ? null : testOn.stringValue,
-        timeout: timeout,
-        skip: skip);
-  } on SourceSpanFormatException catch (error) {
-    var file = new SourceFile(new File(path).readAsStringSync(),
-        url: p.toUri(path));
-    var span = contextualizeSpan(error.span, testOn, file);
-    if (span == null) rethrow;
-    throw new SourceSpanFormatException(error.message, span);
+    try {
+      return new Metadata.parse(
+          testOn: testOn == null ? null : testOn.stringValue,
+          timeout: timeout,
+          skip: skip);
+    } on SourceSpanFormatException catch (error) {
+      var file = new SourceFile(new File(_path).readAsStringSync(),
+          url: p.toUri(_path));
+      var span = contextualizeSpan(error.span, testOn, file);
+      if (span == null) rethrow;
+      throw new SourceSpanFormatException(error.message, span);
+    }
   }
-}
 
-/// Parses a `@TestOn` annotation.
-///
-/// [annotation] is the annotation. [constructorName] is the name of the named
-/// constructor for the annotation, if any. [path] is the path to the file from
-/// which the annotation was parsed.
-StringLiteral _parseTestOn(Annotation annotation, String constructorName,
-    String path) {
-  if (constructorName != null) {
-    throw new SourceSpanFormatException(
-        'TestOn doesn\'t have a constructor named "$constructorName".',
-        _spanFor(annotation, path));
+  /// Parses a `@TestOn` annotation.
+  ///
+  /// [annotation] is the annotation. [constructorName] is the name of the named
+  /// constructor for the annotation, if any.
+  StringLiteral _parseTestOn(Annotation annotation, String constructorName) {
+    _assertConstructorName(constructorName, 'TestOn', annotation);
+    _assertArguments(annotation.arguments, 'TestOn', annotation, positional: 1);
+    return _parseString(annotation.arguments.arguments.first);
   }
 
-  if (annotation.arguments == null) {
-    throw new SourceSpanFormatException(
-        'TestOn takes one argument.', _spanFor(annotation, path));
-  }
+  /// Parses a `@Timeout` annotation.
+  ///
+  /// [annotation] is the annotation. [constructorName] is the name of the named
+  /// constructor for the annotation, if any.
+  Timeout _parseTimeout(Annotation annotation, String constructorName) {
+    _assertConstructorName(constructorName, 'Timeout', annotation,
+        validNames: [null, 'factor']);
 
-  var args = annotation.arguments.arguments;
-  if (args.isEmpty) {
-    throw new SourceSpanFormatException(
-        'TestOn takes one argument.', _spanFor(annotation.arguments, path));
-  }
+    var description = 'Timeout';
+    if (constructorName != null) description += '.$constructorName'; 
 
-  if (args.first is NamedExpression) {
-    throw new SourceSpanFormatException(
-        "TestOn doesn't take named parameters.", _spanFor(args.first, path));
-  }
+    _assertArguments(annotation.arguments, description, annotation,
+        positional: 1);
 
-  if (args.length > 1) {
-    throw new SourceSpanFormatException(
-        "TestOn takes only one argument.",
-        _spanFor(annotation.arguments, path));
+    var args = annotation.arguments.arguments;
+    if (constructorName == null) return new Timeout(_parseDuration(args.first));
+    return new Timeout.factor(_parseNum(args.first));
   }
 
-  if (args.first is! StringLiteral) {
-    throw new SourceSpanFormatException(
-        "TestOn takes a String.", _spanFor(args.first, path));
+  /// Parses a `@Skip` annotation.
+  ///
+  /// [annotation] is the annotation. [constructorName] is the name of the named
+  /// constructor for the annotation, if any.
+  ///
+  /// Returns either `true` or a reason string.
+  _parseSkip(Annotation annotation, String constructorName) {
+    _assertConstructorName(constructorName, 'Skip', annotation);
+    _assertArguments(annotation.arguments, 'Skip', annotation, optional: 1);
+
+    var args = annotation.arguments.arguments;
+    return args.isEmpty ? true : _parseString(args.first).stringValue;
   }
 
-  return args.first;
-}
+  /// Parses a `const Duration` expression.
+  Duration _parseDuration(Expression expression) {
+    _parseConstructor(expression, 'Duration');
 
-/// Parses a `@Timeout` annotation.
-///
-/// [annotation] is the annotation. [constructorName] is the name of the named
-/// constructor for the annotation, if any. [path] is the path to the file from
-/// which the annotation was parsed.
-Timeout _parseTimeout(Annotation annotation, String constructorName,
-    String path) {
-  if (constructorName != null && constructorName != 'factor') {
-    throw new SourceSpanFormatException(
-        'Timeout doesn\'t have a constructor named "$constructorName".',
-        _spanFor(annotation, path));
-  }
+    var constructor = expression as InstanceCreationExpression;
+    var values = _assertArguments(
+        constructor.argumentList, 'Duration', constructor, named: [
+      'days', 'hours', 'minutes', 'seconds', 'milliseconds', 'microseconds'
+    ]);
 
-  var description = 'Timeout';
-  if (constructorName != null) description += '.$constructorName'; 
+    for (var key in values.keys.toList()) {
+      if (values.containsKey(key)) values[key] = _parseInt(values[key]);
+    }
 
-  if (annotation.arguments == null) {
-    throw new SourceSpanFormatException(
-        '$description takes one argument.', _spanFor(annotation, path));
+    return new Duration(
+        days: values["days"] == null ? 0 : values["days"],
+        hours: values["hours"] == null ? 0 : values["hours"],
+        minutes: values["minutes"] == null ? 0 : values["minutes"],
+        seconds: values["seconds"] == null ? 0 : values["seconds"],
+        milliseconds: values["milliseconds"] == null ? 0 : values["milliseconds"],
+        microseconds:
+            values["microseconds"] == null ? 0 : values["microseconds"]);
   }
 
-  var args = annotation.arguments.arguments;
-  if (args.isEmpty) {
+  /// Asserts that [existing] is null.
+  ///
+  /// [name] is the name of the annotation and [node] is its location, used for
+  /// error reporting.
+  void _assertSingleAnnotation(Object existing, String name, AstNode node) {
+    if (existing == null) return;
     throw new SourceSpanFormatException(
-        '$description takes one argument.',
-        _spanFor(annotation.arguments, path));
+        "Only a single $name annotation may be used for a given test file.",
+        _spanFor(node));
   }
 
-  if (args.first is NamedExpression) {
-    throw new SourceSpanFormatException(
-        "$description doesn't take named parameters.",
-        _spanFor(args.first, path));
+  /// Asserts that [constructorName] is a valid constructor name for an AST
+  /// node.
+  ///
+  /// [nodeName] is the name of the class being constructed, and [node] is the
+  /// AST node for that class. [validNames], if passed, is the set of valid
+  /// constructor names; if an unnamed constructor is valid, it should include
+  /// `null`. By default, only an unnamed constructor is allowed.
+  void _assertConstructorName(String constructorName, String nodeName,
+      AstNode node, {Iterable<String> validNames}) {
+    if (validNames == null) validNames = [null];
+    if (validNames.contains(constructorName)) return;
+
+    if (constructorName == null) {
+      throw new SourceSpanFormatException(
+          "$nodeName doesn't have an unnamed constructor.",
+          _spanFor(node));
+    } else {
+      throw new SourceSpanFormatException(
+          '$nodeName doesn\'t have a constructor named "$constructorName".',
+          _spanFor(node));
+    }
   }
 
-  if (args.length > 1) {
-    throw new SourceSpanFormatException(
-        "$description takes only one argument.",
-        _spanFor(annotation.arguments, path));
-  }
+  /// Parses a constructor invocation for [className].
+  ///
+  /// [validNames], if passed, is the set of valid constructor names; if an
+  /// unnamed constructor is valid, it should include `null`. By default, only
+  /// an unnamed constructor is allowed.
+  ///
+  /// Returns the name of the named constructor, if any.
+  String _parseConstructor(Expression expression, String className,
+      {Iterable<String> validNames}) {
+    if (validNames == null) validNames = [null];
+
+    if (expression is! InstanceCreationExpression) {
+      throw new SourceSpanFormatException(
+          "Expected a $className.", _spanFor(expression));
+    }
 
-  if (constructorName == null) {
-    return new Timeout(_parseDuration(args.first, path));
-  } else {
-    return new Timeout.factor(_parseNum(args.first, path));
-  }
-}
+    var constructor = expression as InstanceCreationExpression;
+    if (constructor.constructorName.type.name.name != className) {
+      throw new SourceSpanFormatException(
+          "Expected a $className.", _spanFor(constructor));
+    }
 
-/// Parses a `@Skip` annotation.
-///
-/// [annotation] is the annotation. [constructorName] is the name of the named
-/// constructor for the annotation, if any. [path] is the path to the file from
-/// which the annotation was parsed.
-///
-/// Returns either `true` or a reason string.
-_parseSkip(Annotation annotation, String constructorName, String path) {
-  if (constructorName != null) {
-    throw new SourceSpanFormatException(
-        'Skip doesn\'t have a constructor named "$constructorName".',
-        _spanFor(annotation, path));
-  }
+    if (constructor.keyword.lexeme != "const") {
+      throw new SourceSpanFormatException(
+          "$className must use a const constructor.", _spanFor(constructor));
+    }
 
-  if (annotation.arguments == null) {
-    throw new SourceSpanFormatException(
-        'Skip must have parentheses.', _spanFor(annotation, path));
+    var name = constructor.constructorName == null
+        ? null
+        : constructor.constructorName.name;
+    _assertConstructorName(name, className, expression,
+        validNames: validNames);
+    return name;
   }
 
-  var args = annotation.arguments.arguments;
-  if (args.length > 1) {
-    throw new SourceSpanFormatException(
-        'Skip takes zero arguments or one argument.',
-        _spanFor(annotation.arguments, path));
-  }
+  /// Assert that [arguments] is a valid argument list.
+  ///
+  /// [name] describes the function and [node] is its AST node. [positional] is
+  /// the number of required positional arguments, [optional] the number of
+  /// optional positional arguments, and [named] the set of valid argument
+  /// names.
+  ///
+  /// The set of parsed named arguments is returned.
+  Map<String, Expression> _assertArguments(ArgumentList arguments, String name,
+      AstNode node, {int positional, int optional, Iterable<String> named}) {
+    if (positional == null) positional = 0;
+    if (optional == null) optional = 0;
+    if (named == null) named = new Set();
+
+    if (arguments == null) {
+      throw new SourceSpanFormatException(
+          '$name takes arguments.', _spanFor(node));
+    }
 
-  if (args.isEmpty) return true;
+    var actualNamed = arguments.arguments
+        .where((arg) => arg is NamedExpression).toList();
+    if (!actualNamed.isEmpty && named.isEmpty) {
+      throw new SourceSpanFormatException(
+          "$name doesn't take named arguments.", _spanFor(actualNamed.first));
+    }
 
-  if (args.first is NamedExpression) {
-    throw new SourceSpanFormatException(
-        "Skip doesn't take named parameters.", _spanFor(args.first, path));
-  }
+    var namedValues = {};
+    for (var argument in actualNamed) {
+      var argumentName = argument.name.label.name;
+      if (!named.contains(argumentName)) {
+        throw new SourceSpanFormatException(
+            '$name doesn\'t take an argument named "$argumentName".',
+            _spanFor(argument));
+      } else if (namedValues.containsKey(argumentName)) {
+        throw new SourceSpanFormatException(
+            'An argument named "$argumentName" was already passed.',
+            _spanFor(argument));
+      } else {
+        namedValues[argumentName] = argument.expression;
+      }
+    }
 
-  if (args.first is! StringLiteral) {
-    throw new SourceSpanFormatException(
-        "Skip takes a String.", _spanFor(args.first, path));
-  }
+    var actualPositional = arguments.arguments.length - actualNamed.length;
+    if (actualPositional < positional) {
+      var buffer = new StringBuffer("$name takes ");
+      if (optional != 0) buffer.write("at least ");
+      buffer.write("$positional argument");
+      if (positional > 1) buffer.write("s");
+      buffer.write(".");
+      throw new SourceSpanFormatException(
+          buffer.toString(), _spanFor(arguments));
+    }
 
-  return args.first.stringValue;
-}
+    if (actualPositional > positional + optional) {
+      if (optional + positional == 0) {
+        var buffer = new StringBuffer("$name doesn't take ");
+        if (!named.isEmpty) buffer.write("positional ");
+        buffer.write("arguments.");
+        throw new SourceSpanFormatException(
+            buffer.toString(), _spanFor(arguments));
+      }
 
-/// Parses a `const Duration` expression.
-Duration _parseDuration(Expression expression, String path) {
-  if (expression is! InstanceCreationExpression) {
-    throw new SourceSpanFormatException(
-        "Expected a Duration.",
-        _spanFor(expression, path));
-  }
+      var buffer = new StringBuffer("$name takes ");
+      if (optional != 0) buffer.write("at most ");
+      buffer.write("${positional + optional} argument");
+      if (positional > 1) buffer.write("s");
+      buffer.write(".");
+      throw new SourceSpanFormatException(
+          buffer.toString(), _spanFor(arguments));
+    }
 
-  var constructor = expression as InstanceCreationExpression;
-  if (constructor.constructorName.type.name.name != 'Duration') {
-    throw new SourceSpanFormatException(
-        "Expected a Duration.",
-        _spanFor(constructor, path));
+    return namedValues;
   }
 
-  if (constructor.keyword.lexeme != "const") {
+  /// Parses a constant number literal.
+  num _parseNum(Expression expression) {
+    if (expression is IntegerLiteral) return expression.value;
+    if (expression is DoubleLiteral) return expression.value;
     throw new SourceSpanFormatException(
-        "Duration must use a const constructor.",
-        _spanFor(constructor, path));
+        "Expected a number.", _spanFor(expression));
   }
 
-  if (constructor.constructorName.name != null) {
+  /// Parses a constant int literal.
+  int _parseInt(Expression expression) {
+    if (expression is IntegerLiteral) return expression.value;
     throw new SourceSpanFormatException(
-        "Duration doesn't have a constructor named "
-            '"${constructor.constructorName}".',
-        _spanFor(constructor.constructorName, path));
+        "Expected an integer.", _spanFor(expression));
   }
 
-  var values = {};
-  var args = constructor.argumentList.arguments;
-  for (var argument in args) {
-    if (argument is! NamedExpression) {
-      throw new SourceSpanFormatException(
-          "Duration doesn't take positional arguments.",
-          _spanFor(argument, path));
-    }
-
-    var name = argument.name.label.name;
-    if (!_durationArgs.contains(name)) {
-      throw new SourceSpanFormatException(
-          'Duration doesn\'t take an argument named "$name".',
-          _spanFor(argument, path));
-    }
-
-    if (values.containsKey(name)) {
-      throw new SourceSpanFormatException(
-          'An argument named "$name" was already passed.',
-          _spanFor(argument, path));
-    }
-
-    values[name] = _parseInt(argument.expression, path);
+  /// Parses a constant String literal.
+  StringLiteral _parseString(Expression expression) {
+    if (expression is StringLiteral) return expression;
+    throw new SourceSpanFormatException(
+        "Expected a String.", _spanFor(expression));
   }
 
-  return new Duration(
-      days: values["days"] == null ? 0 : values["days"],
-      hours: values["hours"] == null ? 0 : values["hours"],
-      minutes: values["minutes"] == null ? 0 : values["minutes"],
-      seconds: values["seconds"] == null ? 0 : values["seconds"],
-      milliseconds: values["milliseconds"] == null ? 0 : values["milliseconds"],
-      microseconds:
-          values["microseconds"] == null ? 0 : values["microseconds"]);
-}
-
-/// Parses a constant number literal.
-num _parseNum(Expression expression, String path) {
-  if (expression is IntegerLiteral) return expression.value;
-  if (expression is DoubleLiteral) return expression.value;
-  throw new SourceSpanFormatException(
-      "Expected a number.", _spanFor(expression, path));
-}
-
-/// Parses a constant int literal.
-int _parseInt(Expression expression, String path) {
-  if (expression is IntegerLiteral) return expression.value;
-  throw new SourceSpanFormatException(
-      "Expected an integer.", _spanFor(expression, path));
-}
-
-/// Creates a [SourceSpan] for [node].
-SourceSpan _spanFor(AstNode node, String path) =>
+  /// Creates a [SourceSpan] for [node].
+  SourceSpan _spanFor(AstNode node) {
     // Load a SourceFile from scratch here since we're only ever going to emit
     // one error per file anyway.
-    new SourceFile(new File(path).readAsStringSync(), url: p.toUri(path))
+    var contents = new File(_path).readAsStringSync();
+    return new SourceFile(contents, url: p.toUri(_path))
         .span(node.offset, node.end);
+  }
+}
-- 
GitLab