Shorthand syntax for hosted dependencies (#3133)

If you have language-version 2.15 or higher you can specify hosted dependencies like:
```
dependencies:
  foo:
    hosted: <url>
  bar: # alternatively
    hosted:
      url: <url>
```
Before you had to write:

```
  dependencies:
    foo:
       hosted:
          name: foo
          url: <url>
```
diff --git a/lib/src/command/add.dart b/lib/src/command/add.dart
index b481fe4..09cc426 100644
--- a/lib/src/command/add.dart
+++ b/lib/src/command/add.dart
@@ -389,8 +389,13 @@
         pubspecInformation = {'hosted': hostInfo};
       }
 
-      packageRange = PackageRange(packageName, cache.sources['hosted'],
-          constraint ?? VersionConstraint.any, hostInfo ?? packageName);
+      packageRange = cache.hosted.source
+          .parseRef(
+            packageName,
+            hostInfo,
+            languageVersion: entrypoint.root.pubspec.languageVersion,
+          )
+          .withConstraint(constraint ?? VersionConstraint.any);
     }
 
     if (pubspecInformation is Map && constraint != null) {
diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart
index b700638..cc75b2d 100644
--- a/lib/src/pubspec.dart
+++ b/lib/src/pubspec.dart
@@ -441,10 +441,8 @@
       VersionConstraint versionConstraint = VersionRange();
       var features = const <String, FeatureDependency>{};
       if (spec == null) {
-        descriptionNode = nameNode;
         sourceName = _sources!.defaultSource.name;
       } else if (spec is String) {
-        descriptionNode = nameNode;
         sourceName = _sources!.defaultSource.name;
         versionConstraint = _parseVersionConstraint(specNode);
       } else if (spec is Map) {
@@ -468,7 +466,6 @@
         } else if (sourceNames.isEmpty) {
           // Default to a hosted dependency if no source is specified.
           sourceName = 'hosted';
-          descriptionNode = nameNode;
         }
 
         sourceName ??= sourceNames.single;
@@ -491,8 +488,12 @@
           pubspecPath = path.fromUri(_location);
         }
 
-        return _sources![sourceName]!.parseRef(name, descriptionNode?.value,
-            containingPath: pubspecPath);
+        return _sources![sourceName]!.parseRef(
+          name,
+          descriptionNode?.value,
+          containingPath: pubspecPath,
+          languageVersion: languageVersion,
+        );
       }, targetPackage: name);
 
       dependencies[name] =
@@ -611,6 +612,9 @@
     try {
       return fn();
     } on FormatException catch (e) {
+      // If we already have a pub exception with a span, re-use that
+      if (e is PubspecException) rethrow;
+
       var msg = 'Invalid $description';
       if (targetPackage != null) {
         msg = '$msg in the "$name" pubspec on the "$targetPackage" dependency';
diff --git a/lib/src/source.dart b/lib/src/source.dart
index 53c694d..add55fa 100644
--- a/lib/src/source.dart
+++ b/lib/src/source.dart
@@ -8,6 +8,7 @@
 import 'package:pub_semver/pub_semver.dart';
 
 import 'exceptions.dart';
+import 'language_version.dart';
 import 'package_name.dart';
 import 'pubspec.dart';
 import 'system_cache.dart';
@@ -76,16 +77,27 @@
   /// should be interpreted. This will be called during parsing to validate that
   /// the given [description] is well-formed according to this source, and to
   /// give the source a chance to canonicalize the description.
+  /// For simple hosted dependencies like `foo:` or `foo: ^1.2.3`, the
+  /// [description] may also be `null`.
   ///
   /// [containingPath] is the path to the pubspec where this description
   /// appears. It may be `null` if the description is coming from some in-memory
   /// source (such as pulling down a pubspec from pub.dartlang.org).
   ///
+  /// [languageVersion] is the minimum Dart version parsed from the pubspec's
+  /// `environment` field. Source implementations may use this parameter to only
+  /// support specific syntax for some versions.
+  ///
   /// The description in the returned [PackageRef] need bear no resemblance to
   /// the original user-provided description.
   ///
   /// Throws a [FormatException] if the description is not valid.
-  PackageRef parseRef(String name, description, {String? containingPath});
+  PackageRef parseRef(
+    String name,
+    description, {
+    String? containingPath,
+    required LanguageVersion languageVersion,
+  });
 
   /// Parses a [PackageId] from a name and a serialized description.
   ///
diff --git a/lib/src/source/git.dart b/lib/src/source/git.dart
index 161c540..0d98ae9 100644
--- a/lib/src/source/git.dart
+++ b/lib/src/source/git.dart
@@ -12,6 +12,7 @@
 
 import '../git.dart' as git;
 import '../io.dart';
+import '../language_version.dart';
 import '../log.dart' as log;
 import '../package.dart';
 import '../package_name.dart';
@@ -43,7 +44,12 @@
   }
 
   @override
-  PackageRef parseRef(String name, description, {String? containingPath}) {
+  PackageRef parseRef(
+    String name,
+    description, {
+    String? containingPath,
+    LanguageVersion? languageVersion,
+  }) {
     dynamic url;
     dynamic ref;
     dynamic path;
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index 803bbc5..cfe582a 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -18,6 +18,7 @@
 import '../exceptions.dart';
 import '../http.dart';
 import '../io.dart';
+import '../language_version.dart';
 import '../log.dart' as log;
 import '../package.dart';
 import '../package_name.dart';
@@ -129,19 +130,19 @@
   /// should be downloaded. [url] most be normalized and validated using
   /// [validateAndNormalizeHostedUrl].
   PackageRef refFor(String name, {Uri? url}) =>
-      PackageRef(name, this, _descriptionFor(name, url));
+      PackageRef(name, this, _HostedDescription(name, url ?? defaultUrl));
 
   /// Returns an ID for a hosted package named [name] at [version].
   ///
   /// If [url] is passed, it's the URL of the pub server from which the package
   /// should be downloaded. [url] most be normalized and validated using
   /// [validateAndNormalizeHostedUrl].
-  PackageId idFor(String name, Version version, {Uri? url}) =>
-      PackageId(name, this, version, _descriptionFor(name, url));
+  PackageId idFor(String name, Version version, {Uri? url}) => PackageId(
+      name, this, version, _HostedDescription(name, url ?? defaultUrl));
 
   /// Returns the description for a hosted package named [name] with the
   /// given package server [url].
-  dynamic _descriptionFor(String name, [Uri? url]) {
+  dynamic _serializedDescriptionFor(String name, [Uri? url]) {
     if (url == null) {
       return name;
     }
@@ -154,54 +155,119 @@
   }
 
   @override
+  dynamic serializeDescription(String containingPath, description) {
+    final desc = _asDescription(description);
+    return _serializedDescriptionFor(desc.packageName, desc.uri);
+  }
+
+  @override
   String formatDescription(description) =>
-      'on ${_parseDescription(description).last}';
+      'on ${_asDescription(description).uri}';
 
   @override
   bool descriptionsEqual(description1, description2) =>
-      _parseDescription(description1) == _parseDescription(description2);
+      _asDescription(description1) == _asDescription(description2);
 
   @override
-  int hashDescription(description) => _parseDescription(description).hashCode;
+  int hashDescription(description) => _asDescription(description).hashCode;
 
   /// Ensures that [description] is a valid hosted package description.
   ///
-  /// There are two valid formats. A plain string refers to a package with the
-  /// given name from the default host, while a map with keys "name" and "url"
-  /// refers to a package with the given name from the host at the given URL.
+  /// Simple hosted dependencies only consist of a plain string, which is
+  /// resolved against the default host. In this case, [description] will be
+  /// null.
+  ///
+  /// Hosted dependencies may also specify a custom host from which the package
+  /// is fetched. There are two syntactic forms of those dependencies:
+  ///
+  ///  1. With an url and an optional name in a map: `hosted: {url: <url>}`
+  ///  2. With a direct url: `hosted: <url>`
   @override
-  PackageRef parseRef(String name, description, {String? containingPath}) {
-    _parseDescription(description);
-    return PackageRef(name, this, description);
+  PackageRef parseRef(String name, description,
+      {String? containingPath, required LanguageVersion languageVersion}) {
+    return PackageRef(
+        name, this, _parseDescription(name, description, languageVersion));
   }
 
   @override
   PackageId parseId(String name, Version version, description,
       {String? containingPath}) {
-    _parseDescription(description);
-    return PackageId(name, this, version, description);
+    // Old pub versions only wrote `description: <pkg>` into the lock file.
+    if (description is String) {
+      if (description != name) {
+        throw FormatException('The description should be the same as the name');
+      }
+      return PackageId(
+          name, this, version, _HostedDescription(name, defaultUrl));
+    }
+
+    final serializedDescription = (description as Map).cast<String, String>();
+
+    return PackageId(
+      name,
+      this,
+      version,
+      _HostedDescription(serializedDescription['name']!,
+          Uri.parse(serializedDescription['url']!)),
+    );
   }
 
+  _HostedDescription _asDescription(desc) => desc as _HostedDescription;
+
   /// Parses the description for a package.
   ///
   /// If the package parses correctly, this returns a (name, url) pair. If not,
   /// this throws a descriptive FormatException.
-  Pair<String, Uri> _parseDescription(description) {
+  _HostedDescription _parseDescription(
+    String packageName,
+    description,
+    LanguageVersion languageVersion,
+  ) {
+    if (description == null) {
+      // Simple dependency without a `hosted` block, use the default server.
+      return _HostedDescription(packageName, defaultUrl);
+    }
+
+    final canUseShorthandSyntax =
+        languageVersion >= _minVersionForShorterHostedSyntax;
+
     if (description is String) {
-      return Pair<String, Uri>(description, defaultUrl);
+      // Old versions of pub (pre Dart 2.15) interpret `hosted: foo` as
+      // `hosted: {name: foo, url: <default>}`.
+      // For later versions, we treat it as `hosted: {name: <inferred>,
+      // url: foo}` if a user opts in by raising their min SDK environment.
+      //
+      // Since the old behavior is very rarely used and we want to show a
+      // helpful error message if the new syntax is used without raising the SDK
+      // environment, we throw an error if something that looks like a URI is
+      // used as a package name.
+      if (canUseShorthandSyntax) {
+        return _HostedDescription(
+            packageName, validateAndNormalizeHostedUrl(description));
+      } else {
+        if (_looksLikePackageName.hasMatch(description)) {
+          // Valid use of `hosted: package` dependency with an old SDK
+          // environment.
+          return _HostedDescription(description, defaultUrl);
+        } else {
+          throw FormatException(
+            'Using `hosted: <url>` is only supported with a minimum SDK '
+            'constraint of $_minVersionForShorterHostedSyntax.',
+          );
+        }
+      }
     }
 
     if (description is! Map) {
       throw FormatException('The description must be a package name or map.');
     }
 
-    if (!description.containsKey('name')) {
-      throw FormatException("The description map must contain a 'name' key.");
-    }
-
     var name = description['name'];
+    if (canUseShorthandSyntax) name ??= packageName;
+
     if (name is! String) {
-      throw FormatException("The 'name' key must have a string value.");
+      throw FormatException("The 'name' key must have a string value without "
+          'a minimum Dart SDK constraint of $_minVersionForShorterHostedSyntax.0 or higher.');
     }
 
     var url = defaultUrl;
@@ -213,8 +279,26 @@
       url = validateAndNormalizeHostedUrl(u);
     }
 
-    return Pair<String, Uri>(name, url);
+    return _HostedDescription(name, url);
   }
+
+  /// Minimum language version at which short hosted syntax is supported.
+  ///
+  /// This allows `hosted` dependencies to be expressed as:
+  /// ```yaml
+  /// dependencies:
+  ///   foo:
+  ///     hosted: https://some-pub.com/path
+  ///     version: ^1.0.0
+  /// ```
+  ///
+  /// At older versions, `hosted` dependencies had to be a map with a `url` and
+  /// a `name` key.
+  static const LanguageVersion _minVersionForShorterHostedSyntax =
+      LanguageVersion(2, 15);
+
+  static final RegExp _looksLikePackageName =
+      RegExp(r'^[a-zA-Z_]+[a-zA-Z0-9_]*$');
 }
 
 /// Information about a package version retrieved from /api/packages/$package
@@ -226,6 +310,28 @@
   _VersionInfo(this.pubspec, this.archiveUrl, this.status);
 }
 
+/// The [PackageName.description] for a [HostedSource], storing the package name
+/// and resolved URI of the package server.
+class _HostedDescription {
+  final String packageName;
+  final Uri uri;
+
+  _HostedDescription(this.packageName, this.uri) {
+    ArgumentError.checkNotNull(packageName, 'packageName');
+    ArgumentError.checkNotNull(uri, 'uri');
+  }
+
+  @override
+  int get hashCode => Object.hash(packageName, uri);
+
+  @override
+  bool operator ==(Object other) {
+    return other is _HostedDescription &&
+        other.packageName == packageName &&
+        other.uri == uri;
+  }
+}
+
 /// The [BoundSource] for [HostedSource].
 class BoundHostedSource extends CachedSource {
   @override
@@ -277,9 +383,9 @@
     final url = _listVersionsUrl(ref.description);
     log.io('Get versions from $url.');
 
-    String bodyText;
-    Map<String, dynamic>? body;
-    Map<PackageId, _VersionInfo>? result;
+    late final String bodyText;
+    late final dynamic body;
+    late final Map<PackageId, _VersionInfo> result;
     try {
       // TODO(sigurdm): Implement cancellation of requests. This probably
       // requires resolution of: https://github.com/dart-lang/sdk/issues/22265.
@@ -288,16 +394,20 @@
         serverUrl,
         (client) => client.read(url, headers: pubApiHeaders),
       );
-      body = jsonDecode(bodyText);
-      result = _versionInfoFromPackageListing(body!, ref, url);
+      final decoded = jsonDecode(bodyText);
+      if (decoded is! Map<String, dynamic>) {
+        throw FormatException('version listing must be a mapping');
+      }
+      body = decoded;
+      result = _versionInfoFromPackageListing(body, ref, url);
     } on Exception catch (error, stackTrace) {
-      var parsed = source._parseDescription(ref.description);
-      _throwFriendlyError(error, stackTrace, parsed.first, parsed.last);
+      var parsed = source._asDescription(ref.description);
+      _throwFriendlyError(error, stackTrace, parsed.packageName, parsed.uri);
     }
 
     // Cache the response on disk.
     // Don't cache overly big responses.
-    if (body!.length < 100 * 1024) {
+    if (bodyText.length < 100 * 1024) {
       await _cacheVersionListingResponse(body, ref);
     }
     return result;
@@ -466,8 +576,8 @@
 
   // The path where the response from the package-listing api is cached.
   String _versionListingCachePath(PackageRef ref) {
-    final parsed = source._parseDescription(ref.description);
-    final dir = _urlToDirectory(parsed.last);
+    final parsed = source._asDescription(ref.description);
+    final dir = _urlToDirectory(parsed.uri);
     // Use a dot-dir because older versions of pub won't choke on that
     // name when iterating the cache (it is not listed by [listDir]).
     return p.join(systemCacheRoot, dir, _versionListingDirectory,
@@ -494,16 +604,16 @@
   /// Parses [description] into its server and package name components, then
   /// converts that to a Uri for listing versions of the given package.
   Uri _listVersionsUrl(description) {
-    final parsed = source._parseDescription(description);
-    final hostedUrl = parsed.last;
-    final package = Uri.encodeComponent(parsed.first);
+    final parsed = source._asDescription(description);
+    final hostedUrl = parsed.uri;
+    final package = Uri.encodeComponent(parsed.packageName);
     return hostedUrl.resolve('api/packages/$package');
   }
 
   /// Parses [description] into server name component.
   Uri _hostedUrl(description) {
-    final parsed = source._parseDescription(description);
-    return parsed.last;
+    final parsed = source._asDescription(description);
+    return parsed.uri;
   }
 
   /// Retrieves the pubspec for a specific version of a package that is
@@ -535,9 +645,9 @@
   /// package downloaded from that site.
   @override
   String getDirectoryInCache(PackageId id) {
-    var parsed = source._parseDescription(id.description);
-    var dir = _urlToDirectory(parsed.last);
-    return p.join(systemCacheRoot, dir, '${parsed.first}-${id.version}');
+    var parsed = source._asDescription(id.description);
+    var dir = _urlToDirectory(parsed.uri);
+    return p.join(systemCacheRoot, dir, '${parsed.packageName}-${id.version}');
   }
 
   /// Re-downloads all packages that have been previously downloaded into the
@@ -675,8 +785,8 @@
       throw PackageNotFoundException(
           'Package $packageName has no version $version');
     }
-    final parsedDescription = source._parseDescription(id.description);
-    final server = parsedDescription.last;
+    final parsedDescription = source._asDescription(id.description);
+    final server = parsedDescription.uri;
 
     var url = versionInfo.archiveUrl;
     log.io('Get package from $url.');
@@ -713,7 +823,7 @@
   /// this tries to translate into a more user friendly error message.
   ///
   /// Always throws an error, either the original one or a better one.
-  void _throwFriendlyError(
+  Never _throwFriendlyError(
     error,
     StackTrace stackTrace,
     String package,
@@ -809,7 +919,7 @@
   }
 
   /// Returns the server URL for [description].
-  Uri _serverFor(description) => source._parseDescription(description).last;
+  Uri _serverFor(description) => source._asDescription(description).uri;
 
   /// Enables speculative prefetching of dependencies of packages queried with
   /// [getVersions].
@@ -836,9 +946,11 @@
   /// Gets the list of all versions of [ref] that are in the system cache.
   @override
   Future<List<PackageId>> doGetVersions(
-      PackageRef ref, Duration? maxAge) async {
-    var parsed = source._parseDescription(ref.description);
-    var server = parsed.last;
+    PackageRef ref,
+    Duration? maxAge,
+  ) async {
+    var parsed = source._asDescription(ref.description);
+    var server = parsed.uri;
     log.io('Finding versions of ${ref.name} in '
         '$systemCacheRoot/${_urlToDirectory(server)}');
 
diff --git a/lib/src/source/path.dart b/lib/src/source/path.dart
index 545eae8..0401ebd 100644
--- a/lib/src/source/path.dart
+++ b/lib/src/source/path.dart
@@ -9,6 +9,7 @@
 
 import '../exceptions.dart';
 import '../io.dart';
+import '../language_version.dart';
 import '../package_name.dart';
 import '../pubspec.dart';
 import '../source.dart';
@@ -62,7 +63,12 @@
   /// original path but resolved relative to the containing path. The
   /// "relative" key will be `true` if the original path was relative.
   @override
-  PackageRef parseRef(String name, description, {String? containingPath}) {
+  PackageRef parseRef(
+    String name,
+    description, {
+    String? containingPath,
+    LanguageVersion? languageVersion,
+  }) {
     if (description is! String) {
       throw FormatException('The description must be a path string.');
     }
diff --git a/lib/src/source/sdk.dart b/lib/src/source/sdk.dart
index 3ff93fe..f1dde9e 100644
--- a/lib/src/source/sdk.dart
+++ b/lib/src/source/sdk.dart
@@ -7,6 +7,7 @@
 import 'package:pub_semver/pub_semver.dart';
 
 import '../exceptions.dart';
+import '../language_version.dart';
 import '../package_name.dart';
 import '../pubspec.dart';
 import '../sdk.dart';
@@ -33,7 +34,8 @@
 
   /// Parses an SDK dependency.
   @override
-  PackageRef parseRef(String name, description, {String? containingPath}) {
+  PackageRef parseRef(String name, description,
+      {String? containingPath, LanguageVersion? languageVersion}) {
     if (description is! String) {
       throw FormatException('The description must be an SDK name.');
     }
diff --git a/lib/src/source/unknown.dart b/lib/src/source/unknown.dart
index eaf94fd..baa46ea 100644
--- a/lib/src/source/unknown.dart
+++ b/lib/src/source/unknown.dart
@@ -6,6 +6,7 @@
 
 import 'package:pub_semver/pub_semver.dart';
 
+import '../language_version.dart';
 import '../package_name.dart';
 import '../pubspec.dart';
 import '../source.dart';
@@ -42,7 +43,12 @@
   int hashDescription(description) => description.hashCode;
 
   @override
-  PackageRef parseRef(String name, description, {String? containingPath}) =>
+  PackageRef parseRef(
+    String name,
+    description, {
+    String? containingPath,
+    LanguageVersion? languageVersion,
+  }) =>
       PackageRef(name, this, description);
 
   @override
diff --git a/test/hosted/short_syntax_test.dart b/test/hosted/short_syntax_test.dart
new file mode 100644
index 0000000..bfbb3b4
--- /dev/null
+++ b/test/hosted/short_syntax_test.dart
@@ -0,0 +1,89 @@
+// Copyright (c) 2021, 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.
+
+// @dart=2.10
+
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:yaml/yaml.dart';
+
+import '../descriptor.dart' as d;
+import '../test_pub.dart';
+
+void main() {
+  setUp(() => servePackages((b) => b.serve('foo', '1.2.3', pubspec: {
+        'environment': {'sdk': '^2.0.0'}
+      })));
+
+  forBothPubGetAndUpgrade((command) {
+    Future<void> testWith(dynamic dependency) async {
+      await d.dir(appPath, [
+        d.libPubspec(
+          'app',
+          '1.0.0',
+          deps: {'foo': dependency},
+          sdk: '^2.15.0',
+        ),
+      ]).create();
+
+      await pubCommand(
+        command,
+        exitCode: 0,
+        environment: {'_PUB_TEST_SDK_VERSION': '2.15.0'},
+      );
+
+      final lockFile = loadYaml(
+        await File(p.join(d.sandbox, appPath, 'pubspec.lock')).readAsString(),
+      );
+
+      expect(lockFile['packages']['foo'], {
+        'dependency': 'direct main',
+        'source': 'hosted',
+        'description': {
+          'name': 'foo',
+          'url': globalPackageServer.url,
+        },
+        'version': '1.2.3',
+      });
+    }
+
+    test('supports hosted: <url> syntax', () async {
+      return testWith({'hosted': globalPackageServer.url});
+    });
+
+    test('supports hosted map without name', () {
+      return testWith({
+        'hosted': {'url': globalPackageServer.url},
+      });
+    });
+
+    test('interprets hosted string as name for older versions', () async {
+      await d.dir(appPath, [
+        d.libPubspec(
+          'app',
+          '1.0.0',
+          deps: {
+            'foo': {'hosted': 'foo', 'version': '^1.2.3'}
+          },
+          sdk: '^2.0.0',
+        ),
+      ]).create();
+
+      await pubCommand(
+        command,
+        exitCode: 0,
+        environment: {'_PUB_TEST_SDK_VERSION': '2.15.0'},
+      );
+
+      final lockFile = loadYaml(
+        await File(p.join(d.sandbox, appPath, 'pubspec.lock')).readAsString(),
+      );
+
+      expect(lockFile['packages']['foo']['description']['url'],
+          globalPackageServer.url);
+    });
+  });
+}
diff --git a/test/lock_file_test.dart b/test/lock_file_test.dart
index 18edc94..2faf8c5 100644
--- a/test/lock_file_test.dart
+++ b/test/lock_file_test.dart
@@ -4,6 +4,7 @@
 
 // @dart=2.10
 
+import 'package:pub/src/language_version.dart';
 import 'package:pub/src/lock_file.dart';
 import 'package:pub/src/package_name.dart';
 import 'package:pub/src/source.dart';
@@ -22,7 +23,8 @@
       throw UnsupportedError('Cannot download fake packages.');
 
   @override
-  PackageRef parseRef(String name, description, {String containingPath}) {
+  PackageRef parseRef(String name, description,
+      {String containingPath, LanguageVersion languageVersion}) {
     if (!description.endsWith(' desc')) throw FormatException('Bad');
     return PackageRef(name, this, description);
   }
diff --git a/test/pubspec_test.dart b/test/pubspec_test.dart
index 5785b16..c293d85 100644
--- a/test/pubspec_test.dart
+++ b/test/pubspec_test.dart
@@ -4,6 +4,7 @@
 
 // @dart=2.10
 
+import 'package:pub/src/language_version.dart';
 import 'package:pub/src/package_name.dart';
 import 'package:pub/src/pubspec.dart';
 import 'package:pub/src/sdk.dart';
@@ -22,7 +23,8 @@
       throw UnsupportedError('Cannot download fake packages.');
 
   @override
-  PackageRef parseRef(String name, description, {String containingPath}) {
+  PackageRef parseRef(String name, description,
+      {String containingPath, LanguageVersion languageVersion}) {
     if (description != 'ok') throw FormatException('Bad');
     return PackageRef(name, this, description);
   }
@@ -294,6 +296,170 @@
               'local pubspec.');
     });
 
+    group('source dependencies', () {
+      test('with url and name', () {
+        var pubspec = Pubspec.parse(
+          '''
+name: pkg
+dependencies:
+  foo:
+    hosted:
+      url: https://example.org/pub/
+      name: bar
+''',
+          sources,
+        );
+
+        var foo = pubspec.dependencies['foo'];
+        expect(foo.name, equals('foo'));
+        expect(foo.source.name, 'hosted');
+        expect(foo.source.serializeDescription(null, foo.description), {
+          'url': 'https://example.org/pub/',
+          'name': 'bar',
+        });
+      });
+
+      test('with url only', () {
+        var pubspec = Pubspec.parse(
+          '''
+name: pkg
+environment:
+  sdk: ^2.15.0
+dependencies:
+  foo:
+    hosted:
+      url: https://example.org/pub/
+''',
+          sources,
+        );
+
+        var foo = pubspec.dependencies['foo'];
+        expect(foo.name, equals('foo'));
+        expect(foo.source.name, 'hosted');
+        expect(foo.source.serializeDescription(null, foo.description), {
+          'url': 'https://example.org/pub/',
+          'name': 'foo',
+        });
+      });
+
+      test('with url as string', () {
+        var pubspec = Pubspec.parse(
+          '''
+name: pkg
+environment:
+  sdk: ^2.15.0
+dependencies:
+  foo:
+    hosted: https://example.org/pub/
+''',
+          sources,
+        );
+
+        var foo = pubspec.dependencies['foo'];
+        expect(foo.name, equals('foo'));
+        expect(foo.source.name, 'hosted');
+        expect(foo.source.serializeDescription(null, foo.description), {
+          'url': 'https://example.org/pub/',
+          'name': 'foo',
+        });
+      });
+
+      test('interprets string description as name for older versions', () {
+        var pubspec = Pubspec.parse(
+          '''
+name: pkg
+environment:
+  sdk: ^2.14.0
+dependencies:
+  foo:
+    hosted: bar
+''',
+          sources,
+        );
+
+        var foo = pubspec.dependencies['foo'];
+        expect(foo.name, equals('foo'));
+        expect(foo.source.name, 'hosted');
+        expect(foo.source.serializeDescription(null, foo.description), {
+          'url': 'https://pub.dartlang.org',
+          'name': 'bar',
+        });
+      });
+
+      test(
+        'reports helpful span when using new syntax with invalid environment',
+        () {
+          var pubspec = Pubspec.parse('''
+name: pkg
+environment:
+  sdk: invalid value
+dependencies:
+  foo:
+    hosted: https://example.org/pub/
+''', sources);
+
+          expect(
+            () => pubspec.dependencies,
+            throwsA(
+              isA<PubspecException>()
+                  .having((e) => e.span.text, 'span.text', 'invalid value'),
+            ),
+          );
+        },
+      );
+
+      test('without a description', () {
+        var pubspec = Pubspec.parse(
+          '''
+name: pkg
+dependencies:
+  foo:
+''',
+          sources,
+        );
+
+        var foo = pubspec.dependencies['foo'];
+        expect(foo.name, equals('foo'));
+        expect(foo.source.name, 'hosted');
+        expect(foo.source.serializeDescription(null, foo.description), {
+          'url': 'https://pub.dartlang.org',
+          'name': 'foo',
+        });
+      });
+
+      group('throws without a min SDK constraint', () {
+        test('and without a name', () {
+          expectPubspecException(
+              '''
+name: pkg
+dependencies:
+  foo:
+    hosted:
+      url: https://example.org/pub/
+''',
+              (pubspec) => pubspec.dependencies,
+              "The 'name' key must have a string value without a minimum Dart "
+                  'SDK constraint of 2.15.');
+        });
+
+        test(
+          'and a hosted: <value> syntax that looks like an URI was meant',
+          () {
+            expectPubspecException(
+              '''
+name: pkg
+dependencies:
+  foo:
+    hosted: http://pub.example.org
+''',
+              (pubspec) => pubspec.dependencies,
+              'Using `hosted: <url>` is only supported with a minimum SDK constraint of 2.15.',
+            );
+          },
+        );
+      });
+    });
+
     group('git dependencies', () {
       test('path must be a string', () {
         expectPubspecException('''
diff --git a/test/version_solver_test.dart b/test/version_solver_test.dart
index caca301..cf6f65f 100644
--- a/test/version_solver_test.dart
+++ b/test/version_solver_test.dart
@@ -3003,8 +3003,9 @@
   for (var dep in resultPubspec.dependencies.values) {
     expect(ids, contains(dep.name));
     var id = ids.remove(dep.name);
+    final source = dep.source;
 
-    if (dep.source is HostedSource && dep.description is String) {
+    if (source is HostedSource && (dep.description.uri == source.defaultUrl)) {
       // If the dep uses the default hosted source, grab it from the test
       // package server rather than pub.dartlang.org.
       dep = registry.hosted