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);
+}