Add initial package implementation (dart-lang/pubspec_parse#3)

- Pubspec class
- Dependency classes
- top-level parsePubspec function
diff --git a/pkgs/pubspec_parse/.travis.yml b/pkgs/pubspec_parse/.travis.yml
new file mode 100644
index 0000000..ea54694
--- /dev/null
+++ b/pkgs/pubspec_parse/.travis.yml
@@ -0,0 +1,18 @@
+language: dart
+
+dart:
+  - dev
+
+dart_task:
+  - test: -x presubmit-only
+  - test: --run-skipped -t presubmit-only
+  - dartfmt
+  - dartanalyzer: --fatal-infos --fatal-warnings .
+
+# Only building master means that we don't run two builds for each pull request.
+branches:
+  only: [master]
+
+cache:
+  directories:
+    - $HOME/.pub-cache
diff --git a/pkgs/pubspec_parse/CHANGELOG.md b/pkgs/pubspec_parse/CHANGELOG.md
new file mode 100644
index 0000000..39d5143
--- /dev/null
+++ b/pkgs/pubspec_parse/CHANGELOG.md
@@ -0,0 +1,4 @@
+## 0.1.0
+
+- Initial release including the `Pubspec` class and a top-level `parsePubspec`
+  function.
diff --git a/pkgs/pubspec_parse/build.yaml b/pkgs/pubspec_parse/build.yaml
new file mode 100644
index 0000000..ec706e4
--- /dev/null
+++ b/pkgs/pubspec_parse/build.yaml
@@ -0,0 +1,17 @@
+# Read about `build.yaml` at https://pub.dartlang.org/packages/build_config
+# To update generated code, run `pub run build_runner build`
+targets:
+  $default:
+    builders:
+      json_serializable:
+        generate_for:
+          - lib/src/pubspec.dart
+        options:
+          any_map: true
+          checked: true
+          header: |+
+           // Copyright (c) 2018, 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.
+
+           // GENERATED CODE - DO NOT MODIFY BY HAND
diff --git a/pkgs/pubspec_parse/dart_test.yaml b/pkgs/pubspec_parse/dart_test.yaml
new file mode 100644
index 0000000..1d7ac69
--- /dev/null
+++ b/pkgs/pubspec_parse/dart_test.yaml
@@ -0,0 +1,3 @@
+tags:
+  presubmit-only:
+    skip: "Should only be run during presubmit"
diff --git a/pkgs/pubspec_parse/lib/pubspec_parse.dart b/pkgs/pubspec_parse/lib/pubspec_parse.dart
new file mode 100644
index 0000000..3339982
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/pubspec_parse.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2018, 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.
+
+export 'src/dependency.dart'
+    show
+        Dependency,
+        HostedDependency,
+        GitDependency,
+        SdkDependency,
+        PathDependency;
+export 'src/errors.dart' show ParsedYamlException;
+export 'src/functions.dart' show parsePubspec;
+export 'src/pubspec.dart' show Pubspec;
diff --git a/pkgs/pubspec_parse/lib/src/dependency.dart b/pkgs/pubspec_parse/lib/src/dependency.dart
new file mode 100644
index 0000000..8c9ab15
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/dependency.dart
@@ -0,0 +1,144 @@
+// Copyright (c) 2018, 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 'package:json_annotation/json_annotation.dart';
+import 'package:pub_semver/pub_semver.dart';
+
+abstract class Dependency {
+  Dependency._();
+
+  /// Returns `null` if the data could not be parsed.
+  factory Dependency.fromJson(dynamic data) {
+    if (data == null) {
+      return new HostedDependency(VersionConstraint.any);
+    } else if (data is String) {
+      return new HostedDependency(new VersionConstraint.parse(data));
+    } else if (data is Map) {
+      try {
+        return new Dependency._fromMap(data);
+      } on ArgumentError catch (e) {
+        throw new CheckedFromJsonException(
+            data, e.name, 'Dependency', e.message.toString());
+      }
+    }
+
+    return null;
+  }
+
+  factory Dependency._fromMap(Map data) {
+    if (data.entries.isEmpty) {
+      // TODO: provide list of supported keys?
+      throw new CheckedFromJsonException(
+          data, null, 'Dependency', 'Must provide at least one key.');
+    }
+
+    if (data.containsKey('sdk')) {
+      return new SdkDependency.fromData(data);
+    }
+
+    if (data.entries.length > 1) {
+      throw new CheckedFromJsonException(
+          data,
+          data.keys.skip(1).first as String,
+          'Dependency',
+          'Expected only one key.');
+    }
+
+    var entry = data.entries.single;
+    var key = entry.key as String;
+
+    if (entry.value == null) {
+      throw new CheckedFromJsonException(
+          data, key, 'Dependency', 'Cannot be null.');
+    }
+
+    switch (key) {
+      case 'path':
+        return new PathDependency.fromData(entry.value);
+      case 'git':
+        return new GitDependency.fromData(entry.value);
+    }
+
+    return null;
+  }
+
+  String get _info;
+
+  @override
+  String toString() => '$runtimeType: $_info';
+}
+
+class SdkDependency extends Dependency {
+  final String name;
+  final VersionConstraint version;
+
+  SdkDependency(this.name, {this.version}) : super._();
+
+  factory SdkDependency.fromData(Map data) {
+    VersionConstraint version;
+    if (data.containsKey('version')) {
+      version = new VersionConstraint.parse(data['version'] as String);
+    }
+    return new SdkDependency(data['sdk'] as String, version: version);
+  }
+
+  @override
+  String get _info => name;
+}
+
+class GitDependency extends Dependency {
+  final Uri url;
+  final String ref;
+  final String path;
+
+  GitDependency(this.url, this.ref, this.path) : super._();
+
+  factory GitDependency.fromData(Object data) {
+    String url;
+    String path;
+    String ref;
+
+    if (data is String) {
+      url = data;
+    } else if (data is Map) {
+      url = data['url'] as String;
+      path = data['path'] as String;
+      ref = data['ref'] as String;
+    } else {
+      throw new ArgumentError.value(data, 'git', 'Must be a String or a Map.');
+    }
+
+    // TODO: validate `url` is a valid URI
+    return new GitDependency(Uri.parse(url), ref, path);
+  }
+
+  @override
+  String get _info => 'url@$url';
+}
+
+class PathDependency extends Dependency {
+  final String path;
+
+  PathDependency(this.path) : super._();
+
+  factory PathDependency.fromData(Object data) {
+    if (data is String) {
+      return new PathDependency(data);
+    }
+    throw new ArgumentError.value(data, 'path', 'Must be a String.');
+  }
+
+  @override
+  String get _info => 'path@$path';
+}
+
+// TODO: support explicit host?
+class HostedDependency extends Dependency {
+  final VersionConstraint constraint;
+
+  HostedDependency(this.constraint) : super._();
+
+  @override
+  String get _info => constraint.toString();
+}
diff --git a/pkgs/pubspec_parse/lib/src/errors.dart b/pkgs/pubspec_parse/lib/src/errors.dart
new file mode 100644
index 0000000..9b1489c
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/errors.dart
@@ -0,0 +1,87 @@
+// Copyright (c) 2018, 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 'package:json_annotation/json_annotation.dart';
+import 'package:yaml/yaml.dart';
+
+ParsedYamlException parsedYamlException(String message, YamlNode yamlNode) =>
+    new ParsedYamlException._(message, yamlNode);
+
+ParsedYamlException parsedYamlExceptionFromError(
+    CheckedFromJsonException error, StackTrace stack) {
+  var innerError = error.innerError;
+  if (innerError is BadKeyException) {
+    var map = innerError.map;
+    if (map is YamlMap) {
+      var key = map.nodes.keys.singleWhere((key) {
+        return (key as YamlScalar).value == innerError.key;
+      }, orElse: () => null);
+
+      if (key is YamlScalar) {
+        return new ParsedYamlException._(innerError.message, key,
+            innerError: error, innerStack: stack);
+      }
+    }
+  } else if (innerError is ParsedYamlException) {
+    return innerError;
+  }
+
+  var yamlMap = error.map as YamlMap;
+  var yamlNode = yamlMap.nodes[error.key];
+
+  String message;
+  if (yamlNode == null) {
+    assert(error.message != null);
+    message = error.message;
+    yamlNode = yamlMap;
+  } else {
+    if (error.message == null) {
+      message = 'Unsupported value for `${error.key}`.';
+    } else {
+      message = error.message.toString();
+    }
+  }
+
+  return new ParsedYamlException._(message, yamlNode,
+      innerError: error, innerStack: stack);
+}
+
+/// Thrown when parsing a YAML document fails.
+class ParsedYamlException implements Exception {
+  /// Describes the nature of the parse failure.
+  final String message;
+
+  /// The [YamlNode] that corresponds to the exception.
+  final YamlNode yamlNode;
+
+  /// If this exception was thrown as a result of another error,
+  /// contains the source error object.
+  final Object innerError;
+
+  /// If this exception was thrown as a result of another error,
+  /// contains the corresponding [StackTrace].
+  final StackTrace innerStack;
+
+  ParsedYamlException._(this.message, this.yamlNode,
+      {this.innerError, this.innerStack});
+
+  /// Returns [message] formatted with source information provided by
+  /// [yamlNode].
+  String get formatMessage => yamlNode.span.message(message);
+
+  @override
+  String toString() => message;
+}
+
+/// Package-private class representing an invalid key.
+///
+/// Used instead of [CheckedFromJsonException] when highlighting a bad [key]
+/// is desired, instead of the associated value.
+class BadKeyException implements Exception {
+  final Map map;
+  final String key;
+  final String message;
+
+  BadKeyException(this.map, this.key, this.message);
+}
diff --git a/pkgs/pubspec_parse/lib/src/functions.dart b/pkgs/pubspec_parse/lib/src/functions.dart
new file mode 100644
index 0000000..84ef35c
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/functions.dart
@@ -0,0 +1,34 @@
+// Copyright (c) 2018, 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 'package:json_annotation/json_annotation.dart';
+import 'package:yaml/yaml.dart';
+
+import 'errors.dart';
+import 'pubspec.dart';
+
+/// If [sourceUrl] is passed, it's used as the URL from which the YAML
+/// originated for error reporting. It can be a [String], a [Uri], or `null`.
+Pubspec parsePubspec(String yaml, {sourceUrl}) {
+  var item = loadYaml(yaml, sourceUrl: sourceUrl);
+
+  if (item == null) {
+    throw new ArgumentError.notNull('yaml');
+  }
+
+  if (item is! YamlMap) {
+    if (item is YamlNode) {
+      throw parsedYamlException('Does not represent a YAML map.', item);
+    }
+
+    throw new ArgumentError.value(
+        yaml, 'yaml', 'Does not represent a YAML map.');
+  }
+
+  try {
+    return new Pubspec.fromJson(item as YamlMap);
+  } on CheckedFromJsonException catch (error, stack) {
+    throw parsedYamlExceptionFromError(error, stack);
+  }
+}
diff --git a/pkgs/pubspec_parse/lib/src/pubspec.dart b/pkgs/pubspec_parse/lib/src/pubspec.dart
new file mode 100644
index 0000000..6a5c3a6
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/pubspec.dart
@@ -0,0 +1,103 @@
+// Copyright (c) 2018, 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 'package:json_annotation/json_annotation.dart';
+import 'package:pub_semver/pub_semver.dart';
+
+import 'dependency.dart';
+import 'errors.dart';
+
+part 'pubspec.g.dart';
+
+@JsonSerializable(createToJson: false)
+class Pubspec {
+  final String name;
+  final String homepage;
+  final String documentation;
+  final String description;
+  final String author;
+  final List<String> authors;
+
+  @JsonKey(fromJson: _environmentMap)
+  final Map<String, VersionConstraint> environment;
+
+  List<String> get allAuthors {
+    var values = <String>[];
+    if (author != null) {
+      values.add(author);
+    }
+    values.addAll(authors);
+    return values;
+  }
+
+  @JsonKey(fromJson: _versionFromString)
+  final Version version;
+
+  @JsonKey(fromJson: _getDeps, nullable: false)
+  final Map<String, Dependency> dependencies;
+
+  @JsonKey(name: 'dev_dependencies', fromJson: _getDeps, nullable: false)
+  final Map<String, Dependency> devDependencies;
+
+  @JsonKey(name: 'dependency_overrides', fromJson: _getDeps, nullable: false)
+  final Map<String, Dependency> dependencyOverrides;
+
+  Pubspec(
+    this.name, {
+    this.version,
+    this.author,
+    this.environment,
+    List<String> authors,
+    this.homepage,
+    this.documentation,
+    this.description,
+    Map<String, Dependency> dependencies,
+    Map<String, Dependency> devDependencies,
+    Map<String, Dependency> dependencyOverrides,
+  })  : this.authors = authors ?? const [],
+        this.dependencies = dependencies ?? const {},
+        this.devDependencies = devDependencies ?? const {},
+        this.dependencyOverrides = dependencyOverrides ?? const {} {
+    if (name == null || name.isEmpty) {
+      throw new ArgumentError.value(name, 'name', '"name" cannot be empty.');
+    }
+  }
+
+  factory Pubspec.fromJson(Map json) => _$PubspecFromJson(json);
+}
+
+// TODO: maybe move this to `dependencies.dart`?
+Map<String, Dependency> _getDeps(Map source) =>
+    source?.map((k, v) {
+      var key = k as String;
+      var value = new Dependency.fromJson(v);
+      if (value == null) {
+        throw new CheckedFromJsonException(
+            source, key, 'Pubspec', 'Not a valid dependency value.');
+      }
+      return new MapEntry(key, value);
+    }) ??
+    {};
+
+Version _versionFromString(String input) => new Version.parse(input);
+
+Map<String, VersionConstraint> _environmentMap(Map source) =>
+    source.map((key, value) {
+      if (key == 'dart') {
+        // github.com/dart-lang/pub/blob/d84173eeb03c3/lib/src/pubspec.dart#L342
+        // 'dart' is not allowed as a key!
+        throw new BadKeyException(
+            source, 'dart', 'Use "sdk" to for Dart SDK constraints.');
+      }
+
+      VersionConstraint constraint;
+      try {
+        constraint = new VersionConstraint.parse(value as String);
+      } on FormatException catch (e) {
+        throw new CheckedFromJsonException(
+            source, key as String, 'Pubspec', e.message);
+      }
+
+      return new MapEntry(key as String, constraint);
+    });
diff --git a/pkgs/pubspec_parse/lib/src/pubspec.g.dart b/pkgs/pubspec_parse/lib/src/pubspec.g.dart
new file mode 100644
index 0000000..b98d766
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/pubspec.g.dart
@@ -0,0 +1,38 @@
+// Copyright (c) 2018, 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.
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'pubspec.dart';
+
+// **************************************************************************
+// Generator: JsonSerializableGenerator
+// **************************************************************************
+
+Pubspec _$PubspecFromJson(Map json) => $checkedNew(
+        'Pubspec',
+        json,
+        () => new Pubspec($checkedConvert(json, 'name', (v) => v as String),
+            version: $checkedConvert(json, 'version',
+                (v) => v == null ? null : _versionFromString(v as String)),
+            author: $checkedConvert(json, 'author', (v) => v as String),
+            environment: $checkedConvert(json, 'environment',
+                (v) => v == null ? null : _environmentMap(v as Map)),
+            authors: $checkedConvert(json, 'authors',
+                (v) => (v as List)?.map((e) => e as String)?.toList()),
+            homepage: $checkedConvert(json, 'homepage', (v) => v as String),
+            documentation:
+                $checkedConvert(json, 'documentation', (v) => v as String),
+            description:
+                $checkedConvert(json, 'description', (v) => v as String),
+            dependencies: $checkedConvert(
+                json, 'dependencies', (v) => _getDeps(v as Map)),
+            devDependencies: $checkedConvert(
+                json, 'dev_dependencies', (v) => _getDeps(v as Map)),
+            dependencyOverrides: $checkedConvert(
+                json, 'dependency_overrides', (v) => _getDeps(v as Map))),
+        fieldKeyMap: const {
+          'devDependencies': 'dev_dependencies',
+          'dependencyOverrides': 'dependency_overrides'
+        });
diff --git a/pkgs/pubspec_parse/pubspec.yaml b/pkgs/pubspec_parse/pubspec.yaml
new file mode 100644
index 0000000..ae1a617
--- /dev/null
+++ b/pkgs/pubspec_parse/pubspec.yaml
@@ -0,0 +1,20 @@
+name: pubspec_parse
+description: >-
+  Simple package for parsing pubspec.yaml files with a type-safe API and rich
+  error reporting.
+version: 0.1.0-dev
+homepage: https://github.com/dart-lang/pubspec_parse
+author: Dart Team <misc@dartlang.org>
+
+environment:
+  sdk: '>=2.0.0-dev.54 <2.0.0'
+
+dependencies:
+  json_annotation: ^0.2.6
+  pub_semver: ^1.3.2
+  yaml: ^2.1.12
+
+dev_dependencies:
+  build_runner: ^0.8.0
+  json_serializable: ^0.5.5
+  test: ^0.12.0
diff --git a/pkgs/pubspec_parse/test/dependency_test.dart b/pkgs/pubspec_parse/test/dependency_test.dart
new file mode 100644
index 0000000..21c621d
--- /dev/null
+++ b/pkgs/pubspec_parse/test/dependency_test.dart
@@ -0,0 +1,128 @@
+// Copyright (c) 2018, 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 'package:pubspec_parse/pubspec_parse.dart';
+import 'package:test/test.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  test('HostedDepedency', () {
+    var dep = _dependency<HostedDependency>('^1.0.0');
+    expect(dep.constraint.toString(), '^1.0.0');
+    expect(dep.toString(), 'HostedDependency: ^1.0.0');
+  });
+
+  test('SdkDependency without version', () {
+    var dep = _dependency<SdkDependency>({'sdk': 'flutter'});
+    expect(dep.name, 'flutter');
+    expect(dep.version, isNull);
+    expect(dep.toString(), 'SdkDependency: flutter');
+  });
+
+  test('SdkDependency with version', () {
+    var dep = _dependency<SdkDependency>(
+        {'sdk': 'flutter', 'version': '>=1.2.3 <2.0.0'});
+    expect(dep.name, 'flutter');
+    expect(dep.version.toString(), '>=1.2.3 <2.0.0');
+    expect(dep.toString(), 'SdkDependency: flutter');
+  });
+
+  test('GitDependency', () {
+    var dep = _dependency<GitDependency>({'git': 'bob'});
+    expect(dep.url.toString(), 'bob');
+    expect(dep.toString(), 'GitDependency: url@bob');
+  });
+
+  test('HostedDepedency', () {
+    var dep = _dependency<HostedDependency>('^1.0.0');
+    expect(dep.constraint.toString(), '^1.0.0');
+    expect(dep.toString(), 'HostedDependency: ^1.0.0');
+  });
+
+  test('PathDependency', () {
+    var dep = _dependency<PathDependency>({'path': '../path'});
+    expect(dep.path, '../path');
+    expect(dep.toString(), 'PathDependency: path@../path');
+  });
+
+  group('errors', () {
+    test('List', () {
+      _expectThrows([], r'''
+line 4, column 10: Not a valid dependency value.
+  "dep": []
+         ^^''');
+    });
+
+    test('int', () {
+      _expectThrows(42, r'''
+line 4, column 10: Not a valid dependency value.
+  "dep": 42
+         ^^^''');
+    });
+
+    test('empty map', () {
+      _expectThrows({}, r'''
+line 4, column 10: Must provide at least one key.
+  "dep": {}
+         ^^''');
+    });
+
+    test('map with too many keys', () {
+      _expectThrows({'path': 'a', 'git': 'b'}, r'''
+line 5, column 12: Expected only one key.
+   "path": "a",
+           ^^^''');
+    });
+
+    test('git - null content', () {
+      _expectThrows({'git': null}, r'''
+line 5, column 11: Cannot be null.
+   "git": null
+          ^^^^^''');
+    });
+
+    test('git - int content', () {
+      _expectThrows({'git': 42}, r'''
+line 5, column 11: Must be a String or a Map.
+   "git": 42
+          ^^^''');
+    });
+
+    test('path - null content', () {
+      _expectThrows({'path': null}, r'''
+line 5, column 12: Cannot be null.
+   "path": null
+           ^^^^^''');
+    });
+
+    test('path - int content', () {
+      _expectThrows({'path': 42}, r'''
+line 5, column 12: Must be a String.
+   "path": 42
+           ^^^''');
+    });
+  });
+}
+
+void _expectThrows(Object content, String expectedError) {
+  expectParseThrows({
+    'name': 'sample',
+    'dependencies': {'dep': content}
+  }, expectedError);
+}
+
+T _dependency<T extends Dependency>(Object content) {
+  var value = parse({
+    'name': 'sample',
+    'dependencies': {'dep': content}
+  });
+  expect(value.name, 'sample');
+  expect(value.dependencies, hasLength(1));
+
+  var entry = value.dependencies.entries.single;
+  expect(entry.key, 'dep');
+
+  return entry.value as T;
+}
diff --git a/pkgs/pubspec_parse/test/ensure_build_test.dart b/pkgs/pubspec_parse/test/ensure_build_test.dart
new file mode 100644
index 0000000..2842c5a
--- /dev/null
+++ b/pkgs/pubspec_parse/test/ensure_build_test.dart
@@ -0,0 +1,63 @@
+// Copyright (c) 2018, 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.
+
+@TestOn('vm')
+@Tags(const ['presubmit-only'])
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+void main() {
+  // TODO(kevmoo): replace with a common utility
+  //               https://github.com/dart-lang/build/issues/716
+  test('ensure local build succeeds with no changes', () {
+    var pkgRoot = _runProc('git', ['rev-parse', '--show-toplevel']);
+    var currentDir = Directory.current.resolveSymbolicLinksSync();
+
+    if (!p.equals(p.join(pkgRoot), currentDir)) {
+      throw new StateError('Expected the git root ($pkgRoot) '
+          'to match the current directory ($currentDir).');
+    }
+
+    // 1 - get a list of modified `.g.dart` files - should be empty
+    expect(_changedGeneratedFiles(), isEmpty);
+
+    // 2 - run build - should be no output, since nothing should change
+    var result = _runProc('pub',
+        ['run', 'build_runner', 'build', '--delete-conflicting-outputs']);
+
+    print(result);
+    expect(result,
+        contains(new RegExp(r'\[INFO\] Succeeded after \S+ with \d+ outputs')));
+
+    // 3 - get a list of modified `.g.dart` files - should still be empty
+    expect(_changedGeneratedFiles(), isEmpty);
+  });
+}
+
+final _whitespace = new RegExp(r'\s');
+
+Set<String> _changedGeneratedFiles() {
+  var output = _runProc('git', ['status', '--porcelain']);
+
+  return LineSplitter
+      .split(output)
+      .map((line) => line.split(_whitespace).last)
+      .where((path) => path.endsWith('.dart'))
+      .toSet();
+}
+
+String _runProc(String proc, List<String> args) {
+  var result = Process.runSync(proc, args);
+
+  if (result.exitCode != 0) {
+    throw new ProcessException(
+        proc, args, result.stderr as String, result.exitCode);
+  }
+
+  return (result.stdout as String).trim();
+}
diff --git a/pkgs/pubspec_parse/test/parse_test.dart b/pkgs/pubspec_parse/test/parse_test.dart
new file mode 100644
index 0000000..7a4af76
--- /dev/null
+++ b/pkgs/pubspec_parse/test/parse_test.dart
@@ -0,0 +1,95 @@
+// Copyright (c) 2018, 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 'package:test/test.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  test('trival', () {
+    var value = parse({'name': 'sample'});
+    expect(value.name, 'sample');
+    expect(value.authors, isEmpty);
+    expect(value.dependencies, isEmpty);
+  });
+
+  test('one author', () {
+    var value = parse({'name': 'sample', 'author': 'name@example.com'});
+    expect(value.allAuthors, ['name@example.com']);
+  });
+
+  test('one author, via authors', () {
+    var value = parse({
+      'name': 'sample',
+      'authors': ['name@example.com']
+    });
+    expect(value.authors, ['name@example.com']);
+  });
+
+  test('many authors', () {
+    var value = parse({
+      'name': 'sample',
+      'authors': ['name@example.com', 'name2@example.com']
+    });
+    expect(value.authors, ['name@example.com', 'name2@example.com']);
+  });
+
+  test('author and authors', () {
+    var value = parse({
+      'name': 'sample',
+      'author': 'name@example.com',
+      'authors': ['name2@example.com']
+    });
+    expect(value.allAuthors, ['name@example.com', 'name2@example.com']);
+  });
+
+  group('invalid', () {
+    test('null', () {
+      expect(() => parse(null), throwsArgumentError);
+    });
+    test('empty string', () {
+      expect(() => parse(''), throwsArgumentError);
+    });
+    test('array', () {
+      expectParseThrows([], r'''
+line 1, column 1: Does not represent a YAML map.
+[]
+^^''');
+    });
+
+    test('missing name', () {
+      expectParseThrows({}, r'''
+line 1, column 1: "name" cannot be empty.
+{}
+^^''');
+    });
+
+    test('"dart" is an invalid environment key', () {
+      expectParseThrows({
+        'name': 'sample',
+        'environment': {'dart': 'cool'}
+      }, r'''
+line 4, column 3: Use "sdk" to for Dart SDK constraints.
+  "dart": "cool"
+  ^^^^^^''');
+    });
+
+    test('invalid version', () {
+      expectParseThrows({'name': 'sample', 'version': 'invalid'}, r'''
+line 3, column 13: Unsupported value for `version`.
+ "version": "invalid"
+            ^^^^^^^^^''');
+    });
+
+    test('invalid environment value', () {
+      expectParseThrows({
+        'name': 'sample',
+        'environment': {'sdk': 'silly'}
+      }, r'''
+line 4, column 10: Could not parse version "silly". Unknown text at "silly".
+  "sdk": "silly"
+         ^^^^^^^''');
+    });
+  });
+}
diff --git a/pkgs/pubspec_parse/test/test_utils.dart b/pkgs/pubspec_parse/test/test_utils.dart
new file mode 100644
index 0000000..69102bf
--- /dev/null
+++ b/pkgs/pubspec_parse/test/test_utils.dart
@@ -0,0 +1,74 @@
+// Copyright (c) 2018, 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:convert';
+
+import 'package:json_annotation/json_annotation.dart';
+import 'package:pubspec_parse/pubspec_parse.dart';
+import 'package:stack_trace/stack_trace.dart';
+import 'package:test/test.dart';
+
+String _encodeJson(Object input) =>
+    const JsonEncoder.withIndent(' ').convert(input);
+
+Matcher _throwsParsedYamlException(String prettyValue) => throwsA(allOf(
+    const isInstanceOf<ParsedYamlException>(),
+    new FeatureMatcher<ParsedYamlException>('formatMessage', (e) {
+      var message = e.formatMessage;
+      printOnFailure("Actual error format:\nr'''\n$message'''");
+      _printDebugParsedYamlException(e);
+      return message;
+    }, prettyValue)));
+
+void _printDebugParsedYamlException(ParsedYamlException e) {
+  var innerError = e.innerError;
+  var innerStack = e.innerStack;
+
+  if (e.innerError is CheckedFromJsonException) {
+    var cfje = e.innerError as CheckedFromJsonException;
+    if (cfje.innerError != null) {
+      innerError = cfje.innerError;
+      innerStack = cfje.innerStack;
+    }
+  }
+
+  if (innerError != null) {
+    var items = [innerError];
+    if (innerStack != null) {
+      items.add(Trace.format(innerStack, terse: true));
+    }
+
+    var content =
+        LineSplitter.split(items.join('\n')).map((e) => '  $e').join('\n');
+
+    printOnFailure('Inner error details:\n$content');
+  }
+}
+
+Pubspec parse(Object content, {bool quietOnError: false}) {
+  quietOnError ??= false;
+  try {
+    return parsePubspec(_encodeJson(content));
+  } on ParsedYamlException catch (e) {
+    if (!quietOnError) {
+      _printDebugParsedYamlException(e);
+    }
+    rethrow;
+  }
+}
+
+void expectParseThrows(Object content, String expectedError) => expect(
+    () => parse(content, quietOnError: true),
+    _throwsParsedYamlException(expectedError));
+
+// TODO(kevmoo) add this to pkg/matcher – is nice!
+class FeatureMatcher<T> extends CustomMatcher {
+  final dynamic Function(T value) _feature;
+
+  FeatureMatcher(String name, this._feature, matcher)
+      : super('`$name`', '`$name`', matcher);
+
+  @override
+  featureValueOf(covariant T actual) => _feature(actual);
+}