fix(Dependency) ignore extra `version` key with path and git deps (dart-lang/pubspec_parse#14)

Streamline the handling of all dependencies
Improve errors when unsupported keys are provided in dependencies
Better handling (and testing) for invalid `sdk:` values

Handle scp-like URIs for git dependencies
 - See https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a

Fixes https://github.com/dart-lang/pubspec_parse/issues/13

Prepare for release
diff --git a/pkgs/pubspec_parse/CHANGELOG.md b/pkgs/pubspec_parse/CHANGELOG.md
index 720f0e7..30551e7 100644
--- a/pkgs/pubspec_parse/CHANGELOG.md
+++ b/pkgs/pubspec_parse/CHANGELOG.md
@@ -1,3 +1,11 @@
+## 0.1.2
+
+- Allow superfluous `version` keys with `git` and `path` dependencies.
+- Improve errors when unsupported keys are provided in dependencies.
+- Provide better errors with invalid `sdk` dependency values.
+- Support "scp-like syntax" for Git SSH URIs in the form
+  `[user@]host.xz:path/to/repo.git/`.
+
 ## 0.1.1
 
 - Fixed name collision with error type in latest `package:json_annotation`.
diff --git a/pkgs/pubspec_parse/lib/src/dependency.dart b/pkgs/pubspec_parse/lib/src/dependency.dart
index c4f7b26..07d08b5 100644
--- a/pkgs/pubspec_parse/lib/src/dependency.dart
+++ b/pkgs/pubspec_parse/lib/src/dependency.dart
@@ -6,6 +6,8 @@
 import 'package:pub_semver/pub_semver.dart';
 import 'package:yaml/yaml.dart';
 
+import 'errors.dart';
+
 part 'dependency.g.dart';
 
 Map<String, Dependency> parseDeps(Map source) =>
@@ -40,49 +42,55 @@
     }) ??
     {};
 
+const _sourceKeys = const ['sdk', 'git', 'path', 'hosted'];
+
 /// Returns `null` if the data could not be parsed.
 Dependency _fromJson(dynamic data) {
-  var value =
-      SdkDependency.tryFromData(data) ?? HostedDependency.tryFromData(data);
-
-  if (value != null) {
-    return value;
+  if (data is String || data == null) {
+    return _$HostedDependencyFromJson({'version': data});
   }
 
   if (data is Map) {
-    try {
-      return _fromMap(data);
-    } on ArgumentError catch (e) {
-      throw new CheckedFromJsonException(
-          data, e.name, 'Dependency', e.message.toString());
+    var matchedKeys =
+        data.keys.cast<String>().where((key) => key != 'version').toList();
+
+    if (data.isEmpty || (matchedKeys.isEmpty && data.containsKey('version'))) {
+      return _$HostedDependencyFromJson(data);
+    } else {
+      var weirdKey = matchedKeys.firstWhere((k) => !_sourceKeys.contains(k),
+          orElse: () => null);
+
+      if (weirdKey != null) {
+        throw new InvalidKeyException(
+            data, weirdKey, 'Unsupported dependency key.');
+      }
+      if (matchedKeys.length > 1) {
+        throw new CheckedFromJsonException(data, matchedKeys[1], 'Dependency',
+            'A dependency may only have one source.');
+      }
+
+      var key = matchedKeys.single;
+
+      try {
+        switch (key) {
+          case 'git':
+            return new GitDependency.fromData(data[key]);
+          case 'path':
+            return new PathDependency.fromData(data[key]);
+          case 'sdk':
+            return _$SdkDependencyFromJson(data);
+          case 'hosted':
+            return _$HostedDependencyFromJson(data);
+        }
+        throw new StateError('There is a bug in pubspec_parse.');
+      } on ArgumentError catch (e) {
+        throw new CheckedFromJsonException(
+            data, e.name, 'Dependency', e.message.toString());
+      }
     }
   }
 
-  return null;
-}
-
-Dependency _fromMap(Map data) {
-  assert(data.entries.isNotEmpty);
-  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);
-  }
-
+  // Not a String or a Map – return null so parent logic can throw proper error
   return null;
 }
 
@@ -97,26 +105,20 @@
 
 @JsonSerializable(createToJson: false)
 class SdkDependency extends Dependency {
+  @JsonKey(nullable: false, disallowNullValue: true, required: true)
   final String sdk;
   @JsonKey(fromJson: _constraintFromString)
   final VersionConstraint version;
 
   SdkDependency(this.sdk, {this.version}) : super._();
 
-  static SdkDependency tryFromData(Object data) {
-    if (data is Map && data.containsKey('sdk')) {
-      return _$SdkDependencyFromJson(data);
-    }
-    return null;
-  }
-
   @override
   String get _info => sdk;
 }
 
 @JsonSerializable(createToJson: false)
 class GitDependency extends Dependency {
-  @JsonKey(fromJson: _parseUri, required: true, disallowNullValue: true)
+  @JsonKey(fromJson: parseGitUri, required: true, disallowNullValue: true)
   final Uri url;
   final String ref;
   final String path;
@@ -139,7 +141,38 @@
   String get _info => 'url@$url';
 }
 
-Uri _parseUri(String value) => Uri.parse(value);
+Uri parseGitUri(String value) => _tryParseScpUri(value) ?? Uri.parse(value);
+
+/// Supports URIs like `[user@]host.xz:path/to/repo.git/`
+/// See https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a
+Uri _tryParseScpUri(String value) {
+  var colonIndex = value.indexOf(':');
+
+  if (colonIndex < 0) {
+    return null;
+  } else if (colonIndex == value.indexOf('://')) {
+    // If the first colon is part of a scheme, it's not an scp-like URI
+    return null;
+  }
+  var slashIndex = value.indexOf('/');
+
+  if (slashIndex >= 0 && slashIndex < colonIndex) {
+    // Per docs: This syntax is only recognized if there are no slashes before
+    // the first colon. This helps differentiate a local path that contains a
+    // colon. For example the local path foo:bar could be specified as an
+    // absolute path or ./foo:bar to avoid being misinterpreted as an ssh url.
+    return null;
+  }
+
+  var atIndex = value.indexOf('@');
+  if (colonIndex > atIndex) {
+    var user = atIndex >= 0 ? value.substring(0, atIndex) : null;
+    var host = value.substring(atIndex + 1, colonIndex);
+    var path = value.substring(colonIndex + 1);
+    return new Uri(scheme: 'ssh', userInfo: user, host: host, path: path);
+  }
+  return null;
+}
 
 class PathDependency extends Dependency {
   final String path;
@@ -169,22 +202,6 @@
       : this.version = version ?? VersionConstraint.any,
         super._();
 
-  static HostedDependency tryFromData(Object data) {
-    if (data == null || data is String) {
-      data = {'version': data};
-    }
-
-    if (data is Map) {
-      if (data.isEmpty ||
-          data.containsKey('version') ||
-          data.containsKey('hosted')) {
-        return _$HostedDependencyFromJson(data);
-      }
-    }
-
-    return null;
-  }
-
   @override
   String get _info => version.toString();
 }
@@ -194,7 +211,7 @@
   @JsonKey(required: true, disallowNullValue: true)
   final String name;
 
-  @JsonKey(fromJson: _parseUri, disallowNullValue: true)
+  @JsonKey(fromJson: parseGitUri, disallowNullValue: true)
   final Uri url;
 
   HostedDetails(this.name, this.url);
diff --git a/pkgs/pubspec_parse/lib/src/dependency.g.dart b/pkgs/pubspec_parse/lib/src/dependency.g.dart
index ed384a9..e4bca58 100644
--- a/pkgs/pubspec_parse/lib/src/dependency.g.dart
+++ b/pkgs/pubspec_parse/lib/src/dependency.g.dart
@@ -12,6 +12,8 @@
 
 SdkDependency _$SdkDependencyFromJson(Map json) {
   return $checkedNew('SdkDependency', json, () {
+    $checkKeys(json,
+        requiredKeys: const ['sdk'], disallowNullValues: const ['sdk']);
     var val = new SdkDependency(
         $checkedConvert(json, 'sdk', (v) => v as String),
         version: $checkedConvert(json, 'version',
@@ -26,7 +28,7 @@
         requiredKeys: const ['url'], disallowNullValues: const ['url']);
     var val = new GitDependency(
         $checkedConvert(
-            json, 'url', (v) => v == null ? null : _parseUri(v as String)),
+            json, 'url', (v) => v == null ? null : parseGitUri(v as String)),
         $checkedConvert(json, 'ref', (v) => v as String),
         $checkedConvert(json, 'path', (v) => v as String));
     return val;
@@ -56,7 +58,7 @@
     var val = new HostedDetails(
         $checkedConvert(json, 'name', (v) => v as String),
         $checkedConvert(
-            json, 'url', (v) => v == null ? null : _parseUri(v as String)));
+            json, 'url', (v) => v == null ? null : parseGitUri(v as String)));
     return val;
   });
 }
diff --git a/pkgs/pubspec_parse/pubspec.yaml b/pkgs/pubspec_parse/pubspec.yaml
index 78dba00..e219060 100644
--- a/pkgs/pubspec_parse/pubspec.yaml
+++ b/pkgs/pubspec_parse/pubspec.yaml
@@ -2,7 +2,7 @@
 description: >-
   Simple package for parsing pubspec.yaml files with a type-safe API and rich
   error reporting.
-version: 0.1.2-dev
+version: 0.1.2
 homepage: https://github.com/dart-lang/pubspec_parse
 author: Dart Team <misc@dartlang.org>
 
diff --git a/pkgs/pubspec_parse/test/dependency_test.dart b/pkgs/pubspec_parse/test/dependency_test.dart
index 658e132..def243d 100644
--- a/pkgs/pubspec_parse/test/dependency_test.dart
+++ b/pkgs/pubspec_parse/test/dependency_test.dart
@@ -31,10 +31,17 @@
 
     test('map with too many keys', () {
       _expectThrows({'path': 'a', 'git': 'b'}, r'''
-line 5, column 12: Expected only one key.
+line 5, column 12: A dependency may only have one source.
    "path": "a",
            ^^^''');
     });
+
+    test('map with unsupported keys', () {
+      _expectThrows({'bob': 'a', 'jones': 'b'}, r'''
+line 6, column 4: Unsupported dependency key.
+   "jones": "b"
+   ^^^^^^^''');
+    });
   });
 }
 
@@ -94,15 +101,15 @@
               ^^^^^^^^^^^^^^^''');
   });
 
-  test('map w/ unsupported keys', () {
+  test('map w/ extra keys should fail', () {
     _expectThrows({
       'version': '^1.0.0',
       'hosted': {'name': 'hosted_name', 'url': 'hosted_url'},
       'not_supported': null
     }, r'''
-line 4, column 10: Unrecognized keys: [not_supported]; supported keys: [version, hosted]
-  "dep": {
-         ^^''');
+line 10, column 4: Unsupported dependency key.
+   "not_supported": null
+   ^^^^^^^^^^^^^^^''');
   });
 
   test('map w/ version and hosted as String', () {
@@ -138,24 +145,38 @@
 }
 
 void _sdkDependency() {
-  test('SdkDependency without version', () {
+  test('without version', () {
     var dep = _dependency<SdkDependency>({'sdk': 'flutter'});
     expect(dep.sdk, 'flutter');
     expect(dep.version, isNull);
     expect(dep.toString(), 'SdkDependency: flutter');
   });
 
-  test('SdkDependency with version', () {
+  test('with version', () {
     var dep = _dependency<SdkDependency>(
         {'sdk': 'flutter', 'version': '>=1.2.3 <2.0.0'});
     expect(dep.sdk, 'flutter');
     expect(dep.version.toString(), '>=1.2.3 <2.0.0');
     expect(dep.toString(), 'SdkDependency: flutter');
   });
+
+  test('null content', () {
+    _expectThrows({'sdk': null}, r'''
+line 5, column 11: These keys had `null` values, which is not allowed: [sdk]
+   "sdk": null
+          ^^^^^''');
+  });
+
+  test('number content', () {
+    _expectThrows({'sdk': 42}, r'''
+line 5, column 11: Unsupported value for `sdk`.
+   "sdk": 42
+          ^^^''');
+  });
 }
 
 void _gitDependency() {
-  test('GitDependency - string', () {
+  test('string', () {
     var dep = _dependency<GitDependency>({'git': 'url'});
     expect(dep.url.toString(), 'url');
     expect(dep.path, isNull);
@@ -163,7 +184,31 @@
     expect(dep.toString(), 'GitDependency: url@url');
   });
 
-  test('GitDependency - map', () {
+  test('string with version key is ignored', () {
+    // Regression test for https://github.com/dart-lang/pubspec_parse/issues/13
+    var dep = _dependency<GitDependency>({'git': 'url', 'version': '^1.2.3'});
+    expect(dep.url.toString(), 'url');
+    expect(dep.path, isNull);
+    expect(dep.ref, isNull);
+    expect(dep.toString(), 'GitDependency: url@url');
+  });
+
+  test('string with user@ URL', () {
+    var dep = _dependency<GitDependency>({'git': 'git@localhost:dep.git'});
+    expect(dep.url.toString(), 'ssh://git@localhost/dep.git');
+    expect(dep.path, isNull);
+    expect(dep.ref, isNull);
+    expect(dep.toString(), 'GitDependency: url@ssh://git@localhost/dep.git');
+  });
+
+  test('string with random extra key fails', () {
+    _expectThrows({'git': 'url', 'bob': '^1.2.3'}, r'''
+line 6, column 4: Unsupported dependency key.
+   "bob": "^1.2.3"
+   ^^^^^''');
+  });
+
+  test('map', () {
     var dep = _dependency<GitDependency>({
       'git': {'url': 'url', 'path': 'path', 'ref': 'ref'}
     });
@@ -175,7 +220,7 @@
 
   test('git - null content', () {
     _expectThrows({'git': null}, r'''
-line 5, column 11: Cannot be null.
+line 5, column 11: Must be a String or a Map.
    "git": null
           ^^^^^''');
   });
@@ -214,20 +259,34 @@
 }
 
 void _pathDependency() {
-  test('PathDependency', () {
+  test('valid', () {
     var dep = _dependency<PathDependency>({'path': '../path'});
     expect(dep.path, '../path');
     expect(dep.toString(), 'PathDependency: path@../path');
   });
 
-  test('path - null content', () {
+  test('valid with version key is ignored', () {
+    var dep =
+        _dependency<PathDependency>({'path': '../path', 'version': '^1.2.3'});
+    expect(dep.path, '../path');
+    expect(dep.toString(), 'PathDependency: path@../path');
+  });
+
+  test('valid with random extra key fails', () {
+    _expectThrows({'path': '../path', 'bob': '^1.2.3'}, r'''
+line 6, column 4: Unsupported dependency key.
+   "bob": "^1.2.3"
+   ^^^^^''');
+  });
+
+  test('null content', () {
     _expectThrows({'path': null}, r'''
-line 5, column 12: Cannot be null.
+line 5, column 12: Must be a String.
    "path": null
            ^^^^^''');
   });
 
-  test('path - int content', () {
+  test('int content', () {
     _expectThrows({'path': 42}, r'''
 line 5, column 12: Must be a String.
    "path": 42
diff --git a/pkgs/pubspec_parse/test/git_uri_test.dart b/pkgs/pubspec_parse/test/git_uri_test.dart
new file mode 100644
index 0000000..a1cfdf9
--- /dev/null
+++ b/pkgs/pubspec_parse/test/git_uri_test.dart
@@ -0,0 +1,25 @@
+import 'package:test/test.dart';
+
+import 'package:pubspec_parse/src/dependency.dart';
+
+void main() {
+  for (var item in {
+    'git@github.com:google/grinder.dart.git':
+        'ssh://git@github.com/google/grinder.dart.git',
+    'host.xz:path/to/repo.git/': 'ssh://host.xz/path/to/repo.git/',
+    'http:path/to/repo.git/': 'ssh://http/path/to/repo.git/',
+    'file:path/to/repo.git/': 'ssh://file/path/to/repo.git/',
+    './foo:bar': 'foo%3Abar',
+    '/path/to/repo.git/': '/path/to/repo.git/',
+    'file:///path/to/repo.git/': 'file:///path/to/repo.git/',
+  }.entries) {
+    test(item.key, () {
+      var uri = parseGitUri(item.key);
+
+      printOnFailure(
+          [uri.scheme, uri.userInfo, uri.host, uri.port, uri.path].join('\n'));
+
+      expect(uri, Uri.parse(item.value));
+    });
+  }
+}