From b868624b373f25140abbfeea940013214e462bfc Mon Sep 17 00:00:00 2001
From: Natalie Weizenbaum <nweiz@google.com>
Date: Mon, 23 Mar 2015 17:50:48 -0700
Subject: [PATCH] Add a function to parse metadata into a usable object.

See #6

R=rnystrom@google.com

Review URL: https://codereview.chromium.org//1008483003
---
 lib/src/backend/metadata.dart         |  16 +++
 lib/src/frontend/test_on.dart         |  17 +++
 lib/src/runner/parse_metadata.dart    | 110 ++++++++++++++++
 lib/src/util/dart.dart                |  10 --
 pubspec.yaml                          |   1 +
 test/runner/loader_test.dart          |   2 +-
 test/runner/parse_metadata_test.dart  |  94 ++++++++++++++
 test/util/parse_annotations_test.dart | 179 --------------------------
 8 files changed, 239 insertions(+), 190 deletions(-)
 create mode 100644 lib/src/backend/metadata.dart
 create mode 100644 lib/src/frontend/test_on.dart
 create mode 100644 lib/src/runner/parse_metadata.dart
 create mode 100644 test/runner/parse_metadata_test.dart
 delete mode 100644 test/util/parse_annotations_test.dart

diff --git a/lib/src/backend/metadata.dart b/lib/src/backend/metadata.dart
new file mode 100644
index 00000000..738a7cf5
--- /dev/null
+++ b/lib/src/backend/metadata.dart
@@ -0,0 +1,16 @@
+// Copyright (c) 2015, 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.
+
+library unittest.backend.metadata;
+
+/// Metadata for a test or test suite.
+///
+/// This metadata comes from declarations on the test itself; it doesn't include
+/// configuration from the user.
+class Metadata {
+  /// The expressions indicating which platforms the suite supports.
+  final String testOn;
+
+  Metadata(this.testOn);
+}
diff --git a/lib/src/frontend/test_on.dart b/lib/src/frontend/test_on.dart
new file mode 100644
index 00000000..d5ed9754
--- /dev/null
+++ b/lib/src/frontend/test_on.dart
@@ -0,0 +1,17 @@
+// Copyright (c) 2015, 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.
+
+library unittest.frontend.test_on;
+
+/// An annotation indicating which platforms a test or test suite supports.
+///
+/// For the full syntax of [expression], see [the README][readme].
+///
+/// [readme]: https://github.com/dart-lang/unittest/#readme
+class TestOn {
+  /// The expression specifying the platform.
+  final String expression;
+
+  const TestOn(this.expression);
+}
diff --git a/lib/src/runner/parse_metadata.dart b/lib/src/runner/parse_metadata.dart
new file mode 100644
index 00000000..5599922b
--- /dev/null
+++ b/lib/src/runner/parse_metadata.dart
@@ -0,0 +1,110 @@
+// Copyright (c) 2015, 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.
+
+library unittest.runner.parse_metadata;
+
+import 'dart:io';
+
+import 'package:analyzer/analyzer.dart';
+import 'package:analyzer/src/generated/ast.dart';
+import 'package:path/path.dart' as p;
+import 'package:source_span/source_span.dart';
+
+import '../backend/metadata.dart';
+import '../util/dart.dart';
+
+/// 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 testOn;
+
+  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:unittest" 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;
+      }
+    }
+
+    if (name != 'TestOn') continue;
+    if (constructorName != null) {
+      throw new SourceSpanFormatException(
+          'TestOn doesn\'t have a constructor named "$constructorName".',
+          _spanFor(identifier.identifier, path));
+    }
+
+    if (annotation.arguments == null) {
+      throw new SourceSpanFormatException(
+          'TestOn takes one argument.', _spanFor(annotation, path));
+    }
+
+    var args = annotation.arguments.arguments;
+    if (args.isEmpty) {
+      throw new SourceSpanFormatException(
+          'TestOn takes one argument.', _spanFor(annotation.arguments, path));
+    }
+
+    if (args.first is NamedExpression) {
+      throw new SourceSpanFormatException(
+          "TestOn doesn't take named parameters.", _spanFor(args.first, path));
+    }
+
+    if (args.length > 1) {
+      throw new SourceSpanFormatException(
+          "TestOn takes only one argument.",
+          _spanFor(annotation.arguments, path));
+    }
+
+    if (args.first is! StringLiteral) {
+      throw new SourceSpanFormatException(
+          "TestOn takes a String.", _spanFor(args.first, path));
+    }
+
+    if (testOn != null) {
+      throw new SourceSpanFormatException(
+          "Only a single TestOn annotation may be used for a given test file.",
+          _spanFor(annotation, path));
+    }
+
+    testOn = args.first.stringValue;
+  }
+
+  return new Metadata(testOn);
+}
+
+/// Creates a [SourceSpan] for [node].
+SourceSpan _spanFor(AstNode node, String path) =>
+    // 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))
+        .span(node.offset, node.end);
diff --git a/lib/src/util/dart.dart b/lib/src/util/dart.dart
index 76f10b51..5cf02e53 100644
--- a/lib/src/util/dart.dart
+++ b/lib/src/util/dart.dart
@@ -71,13 +71,3 @@ void _isolateBuffer(message) {
     });
   });
 }
-
-/// Parse all the annotations at the beginning of a Dart file.
-///
-/// This will parse annotations until the first non-annotation production is
-/// reached.
-List<Annotation> parseAnnotations(String path) {
-  var contents = new File(path).readAsStringSync();
-  var directives = parseDirectives(contents, name: path).directives;
-  return directives.isEmpty ? [] : directives.first.metadata;
-}
diff --git a/pubspec.yaml b/pubspec.yaml
index 25215147..35be3283 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -13,6 +13,7 @@ dependencies:
   shelf: '^0.5.3'
   shelf_static: '^0.2.0'
   shelf_web_socket: '^0.0.1'
+  source_span: '^1.0.0'
   stack_trace: '^1.2.0'
 
   # Use a tight version constraint to ensure that a constraint on unittest
diff --git a/test/runner/loader_test.dart b/test/runner/loader_test.dart
index c2213416..de83e820 100644
--- a/test/runner/loader_test.dart
+++ b/test/runner/loader_test.dart
@@ -6,7 +6,7 @@ import 'dart:io';
 
 import 'package:path/path.dart' as p;
 import 'package:unittest/src/backend/state.dart';
-import 'package:unittest/src/runner/test_platform.dart';
+import 'package:unittest/src/backend/test_platform.dart';
 import 'package:unittest/src/runner/loader.dart';
 import 'package:unittest/unittest.dart';
 
diff --git a/test/runner/parse_metadata_test.dart b/test/runner/parse_metadata_test.dart
new file mode 100644
index 00000000..5dfa6eaf
--- /dev/null
+++ b/test/runner/parse_metadata_test.dart
@@ -0,0 +1,94 @@
+// Copyright (c) 2015, 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 'package:unittest/unittest.dart';
+import 'package:unittest/src/runner/parse_metadata.dart';
+
+String _sandbox;
+String _path;
+
+void main() {
+  setUp(() {
+    _sandbox = Directory.systemTemp.createTempSync('unittest_').path;
+    _path = p.join(_sandbox, "test.dart");
+  });
+
+  tearDown(() {
+    new Directory(_sandbox).deleteSync(recursive: true);
+  });
+
+  test("returns empty metadata for an empty file", () {
+    new File(_path).writeAsStringSync("");
+    var metadata = parseMetadata(_path);
+    expect(metadata.testOn, isNull);
+  });
+
+  test("ignores irrelevant annotations", () {
+    new File(_path).writeAsStringSync("@Fblthp\n@Fblthp.foo\nlibrary foo;");
+    var metadata = parseMetadata(_path);
+    expect(metadata.testOn, isNull);
+  });
+
+  test("parses a valid annotation", () {
+    new File(_path).writeAsStringSync("@TestOn('foo')\nlibrary foo;");
+    var metadata = parseMetadata(_path);
+    expect(metadata.testOn, equals("foo"));
+  });
+
+  test("parses a prefixed annotation", () {
+    new File(_path).writeAsStringSync(
+        "@foo.TestOn('foo')\n"
+        "import 'package:unittest/unittest.dart' as foo;");
+    var metadata = parseMetadata(_path);
+    expect(metadata.testOn, equals("foo"));
+  });
+
+  test("ignores a constructor named TestOn", () {
+    new File(_path).writeAsStringSync("@foo.TestOn('foo')\nlibrary foo;");
+    var metadata = parseMetadata(_path);
+    expect(metadata.testOn, isNull);
+  });
+
+  group("throws an error for", () {
+    test("a named constructor", () {
+      new File(_path).writeAsStringSync("@TestOn.name('foo')\nlibrary foo;");
+      expect(() => parseMetadata(_path), throwsFormatException);
+    });
+
+    test("no argument list", () {
+      new File(_path).writeAsStringSync("@TestOn\nlibrary foo;");
+      expect(() => parseMetadata(_path), throwsFormatException);
+    });
+
+    test("an empty argument list", () {
+      new File(_path).writeAsStringSync("@TestOn()\nlibrary foo;");
+      expect(() => parseMetadata(_path), throwsFormatException);
+    });
+
+    test("a named argument", () {
+      new File(_path).writeAsStringSync(
+          "@TestOn(expression: 'foo')\nlibrary foo;");
+      expect(() => parseMetadata(_path), throwsFormatException);
+    });
+
+    test("multiple arguments", () {
+      new File(_path).writeAsStringSync("@TestOn('foo', 'bar')\nlibrary foo;");
+      expect(() => parseMetadata(_path), throwsFormatException);
+    });
+
+    test("a non-string argument", () {
+      new File(_path).writeAsStringSync("@TestOn(123)\nlibrary foo;");
+      expect(() => parseMetadata(_path), throwsFormatException);
+    });
+
+    test("multiple @TestOns", () {
+      new File(_path).writeAsStringSync(
+          "@TestOn('foo')\n@TestOn('bar')\nlibrary foo;");
+      expect(() => parseMetadata(_path), throwsFormatException);
+    });
+  });
+}
\ No newline at end of file
diff --git a/test/util/parse_annotations_test.dart b/test/util/parse_annotations_test.dart
deleted file mode 100644
index a655d160..00000000
--- a/test/util/parse_annotations_test.dart
+++ /dev/null
@@ -1,179 +0,0 @@
-// Copyright (c) 2015, 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:analyzer/src/generated/ast.dart';
-import 'package:path/path.dart' as p;
-import 'package:unittest/unittest.dart';
-import 'package:unittest/src/util/dart.dart';
-
-String _sandbox;
-String _path;
-
-void main() {
-  setUp(() {
-    _sandbox = Directory.systemTemp.createTempSync('unittest_').path;
-    _path = p.join(_sandbox, "test.dart");
-  });
-
-  tearDown(() {
-    new Directory(_sandbox).deleteSync(recursive: true);
-  });
-
-  test("with an empty file", () {
-    new File(_path).writeAsStringSync("");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, isEmpty);
-  });
-
-  test("before a library tag", () {
-    new File(_path).writeAsStringSync("@Annotation()\nlibrary foo;");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, hasLength(1));
-    expect(annotations.first.name.name, equals('Annotation'));
-    expect(annotations.first.arguments.arguments, isEmpty);
-  });
-
-  test("before an import", () {
-    new File(_path).writeAsStringSync("@Annotation()\nimport 'foo';");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, hasLength(1));
-    expect(annotations.first.name.name, equals('Annotation'));
-    expect(annotations.first.arguments.arguments, isEmpty);
-  });
-
-  test("multiple annotations", () {
-    new File(_path).writeAsStringSync(
-        "@Annotation1() @Annotation2()\n@Annotation3()\nlibrary foo;");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, hasLength(3));
-    expect(annotations[0].name.name, equals('Annotation1'));
-    expect(annotations[0].arguments.arguments, isEmpty);
-    expect(annotations[1].name.name, equals('Annotation2'));
-    expect(annotations[1].arguments.arguments, isEmpty);
-    expect(annotations[2].name.name, equals('Annotation3'));
-    expect(annotations[2].arguments.arguments, isEmpty);
-  });
-
-  test("with no arguments", () {
-    new File(_path).writeAsStringSync("@Annotation\nlibrary foo;");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, hasLength(1));
-    expect(annotations.first.name.name, equals('Annotation'));
-    expect(annotations.first.arguments, isNull);
-  });
-
-  test("with positional arguments", () {
-    new File(_path).writeAsStringSync("@Annotation('foo', 12)\nlibrary foo;");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, hasLength(1));
-    expect(annotations.first.name.name, equals('Annotation'));
-    var args = annotations.first.arguments.arguments;
-    expect(args, hasLength(2));
-    expect(args[0], new isInstanceOf<StringLiteral>());
-    expect(args[0].stringValue, equals('foo'));
-    expect(args[1], new isInstanceOf<IntegerLiteral>());
-    expect(args[1].value, equals(12));
-  });
-
-  test("with named arguments", () {
-    new File(_path).writeAsStringSync(
-        "@Annotation(name1: 'foo', name2: 12)\nlibrary foo;");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, hasLength(1));
-    expect(annotations.first.name.name, equals('Annotation'));
-    var args = annotations.first.arguments.arguments;
-    expect(args, hasLength(2));
-    expect(args[0], new isInstanceOf<NamedExpression>());
-    expect(args[0].expression, new isInstanceOf<StringLiteral>());
-    expect(args[0].expression.stringValue, equals('foo'));
-    expect(args[1], new isInstanceOf<NamedExpression>());
-    expect(args[1].expression, new isInstanceOf<IntegerLiteral>());
-    expect(args[1].expression.value, equals(12));
-  });
-
-  test("with a prefix/named constructor", () {
-    new File(_path).writeAsStringSync("@Annotation.name()\nlibrary foo;");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, hasLength(1));
-    expect(annotations.first.name, new isInstanceOf<PrefixedIdentifier>());
-    expect(annotations.first.name.prefix.name, equals('Annotation'));
-    expect(annotations.first.name.identifier.name, equals('name'));
-    expect(annotations.first.constructorName, isNull);
-    expect(annotations.first.arguments.arguments, isEmpty);
-  });
-
-  test("with a prefix and named constructor", () {
-    new File(_path).writeAsStringSync(
-        "@prefix.Annotation.name()\nlibrary foo;");
-
-    var annotations = parseAnnotations(_path);
-    expect(annotations, hasLength(1));
-    expect(annotations.first.name, new isInstanceOf<PrefixedIdentifier>());
-    expect(annotations.first.name.prefix.name, equals('prefix'));
-    expect(annotations.first.name.identifier.name, equals('Annotation'));
-    expect(annotations.first.constructorName.name, equals('name'));
-    expect(annotations.first.arguments.arguments, isEmpty);
-  });
-
-  test("annotations after the first directive are ignored", () {
-    new File(_path).writeAsStringSync(
-        "library foo;\n@prefix.Annotation.name()");
-
-    expect(parseAnnotations(_path), isEmpty);
-  });
-
-  group("comments are ignored", () {
-    test("before an annotation", () {
-      new File(_path).writeAsStringSync(
-          "/* comment */@Annotation()\nlibrary foo;");
-
-      var annotations = parseAnnotations(_path);
-      expect(annotations, hasLength(1));
-      expect(annotations.first.name.name, equals('Annotation'));
-      expect(annotations.first.arguments.arguments, isEmpty);
-    });
-
-    test("within an annotation", () {
-      new File(_path).writeAsStringSync(
-          "@Annotation(/* comment */)\nlibrary foo;");
-
-      var annotations = parseAnnotations(_path);
-      expect(annotations, hasLength(1));
-      expect(annotations.first.name.name, equals('Annotation'));
-      expect(annotations.first.arguments.arguments, isEmpty);
-    });
-
-    test("after an annotation", () {
-      new File(_path).writeAsStringSync(
-          "@Annotation()/* comment */\nlibrary foo;");
-
-      var annotations = parseAnnotations(_path);
-      expect(annotations, hasLength(1));
-      expect(annotations.first.name.name, equals('Annotation'));
-      expect(annotations.first.arguments.arguments, isEmpty);
-    });
-
-    test("between annotations", () {
-      new File(_path).writeAsStringSync(
-          "@Annotation1()/* comment */@Annotation2()\nlibrary foo;");
-
-      var annotations = parseAnnotations(_path);
-      expect(annotations, hasLength(2));
-      expect(annotations[0].name.name, equals('Annotation1'));
-      expect(annotations[0].arguments.arguments, isEmpty);
-      expect(annotations[1].name.name, equals('Annotation2'));
-      expect(annotations[1].arguments.arguments, isEmpty);
-    });
-  });
-}
-- 
GitLab