Add support for pubspec overrides file (#3215)

This change adds support for a new file pubspec_overrides.yaml to override parts of pubspec.yaml. Overrides are only active for the get and upgrade commands.

An overrides file has the same structure as pubspec.yaml, but only supports a subset of its fields. Currently, only the following top-level field override pubspec:

dependency_overrides
All other fields in the overrides file causes an error.

A dependency_overrides, in the overrides file, completely replace a dependency_overrides in pubspec.yaml. The two files are merged in a way that preserves source references to provide correct error messages.

When overrides are active, a warning is logged.

Fixes #2161 .
diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart
index cc75b2d..bce47e1 100644
--- a/lib/src/pubspec.dart
+++ b/lib/src/pubspec.dart
@@ -69,12 +69,27 @@
   // initialization can throw a [PubspecException], that error should also be
   // exposed through [allErrors].
 
+  /// The fields of [pubspecOverridesFilename]. `null` if no such file exists or has
+  /// to be considered.
+  final YamlMap? _overridesFileFields;
+
+  String? get _packageName => fields['name'] != null ? name : null;
+
+  /// The name of the manifest file.
+  static const pubspecYamlFilename = 'pubspec.yaml';
+
+  /// The filename of the pubspec overrides file.
+  ///
+  /// This file can contain dependency_overrides that override those in
+  /// pubspec.yaml.
+  static const pubspecOverridesFilename = 'pubspec_overrides.yaml';
+
   /// The registry of sources to use when parsing [dependencies] and
   /// [devDependencies].
   ///
   /// This will be null if this was created using [new Pubspec] or [new
   /// Pubspec.empty].
-  final SourceRegistry? _sources;
+  final SourceRegistry _sources;
 
   /// The location from which the pubspec was loaded.
   ///
@@ -83,14 +98,27 @@
   Uri? get _location => fields.span.sourceUrl;
 
   /// The additional packages this package depends on.
-  Map<String, PackageRange> get dependencies => _dependencies ??=
-      _parseDependencies('dependencies', fields.nodes['dependencies']);
+  Map<String, PackageRange> get dependencies =>
+      _dependencies ??= _parseDependencies(
+          'dependencies',
+          fields.nodes['dependencies'],
+          _sources,
+          languageVersion,
+          _packageName,
+          _location);
 
   Map<String, PackageRange>? _dependencies;
 
   /// The packages this package depends on when it is the root package.
-  Map<String, PackageRange> get devDependencies => _devDependencies ??=
-      _parseDependencies('dev_dependencies', fields.nodes['dev_dependencies']);
+  Map<String, PackageRange> get devDependencies =>
+      _devDependencies ??= _parseDependencies(
+        'dev_dependencies',
+        fields.nodes['dev_dependencies'],
+        _sources,
+        languageVersion,
+        _packageName,
+        _location,
+      );
 
   Map<String, PackageRange>? _devDependencies;
 
@@ -99,9 +127,41 @@
   ///
   /// Dependencies here will replace any dependency on a package with the same
   /// name anywhere in the dependency graph.
-  Map<String, PackageRange> get dependencyOverrides =>
-      _dependencyOverrides ??= _parseDependencies(
-          'dependency_overrides', fields.nodes['dependency_overrides']);
+  ///
+  /// These can occur both in the pubspec.yaml file and the [pubspecOverridesFilename].
+  Map<String, PackageRange> get dependencyOverrides {
+    if (_dependencyOverrides != null) return _dependencyOverrides!;
+    final pubspecOverridesFields = _overridesFileFields;
+    if (pubspecOverridesFields != null) {
+      pubspecOverridesFields.nodes.forEach((key, _) {
+        if (!const {'dependency_overrides'}.contains(key.value)) {
+          throw PubspecException(
+            'pubspec_overrides.yaml only supports the `dependency_overrides` field.',
+            key.span,
+          );
+        }
+      });
+      if (pubspecOverridesFields.containsKey('dependency_overrides')) {
+        _dependencyOverrides = _parseDependencies(
+          'dependency_overrides',
+          pubspecOverridesFields.nodes['dependency_overrides'],
+          _sources,
+          languageVersion,
+          _packageName,
+          _location,
+          fileType: _FileType.pubspecOverrides,
+        );
+      }
+    }
+    return _dependencyOverrides ??= _parseDependencies(
+      'dependency_overrides',
+      fields.nodes['dependency_overrides'],
+      _sources,
+      languageVersion,
+      _packageName,
+      _location,
+    );
+  }
 
   Map<String, PackageRange>? _dependencyOverrides;
 
@@ -140,7 +200,13 @@
           });
 
           var dependencies = _parseDependencies(
-              'dependencies', specNode.nodes['dependencies']);
+            'dependencies',
+            specNode.nodes['dependencies'],
+            _sources,
+            languageVersion,
+            _packageName,
+            _location,
+          );
 
           var sdkConstraints = _parseEnvironment(specNode);
 
@@ -250,7 +316,8 @@
     }
 
     var constraints = {
-      'dart': _parseVersionConstraint(yaml.nodes['sdk'],
+      'dart': _parseVersionConstraint(
+          yaml.nodes['sdk'], _packageName, _FileType.pubspec,
           defaultUpperBoundConstraint: _includeDefaultSdkConstraint
               ? _defaultUpperBoundSdkConstraint
               : null)
@@ -263,10 +330,11 @@
       }
       if (name.value == 'sdk') return;
 
-      constraints[name.value as String] = _parseVersionConstraint(constraint,
-          // Flutter constraints get special treatment, as Flutter won't be
-          // using semantic versioning to mark breaking releases.
-          ignoreUpperBound: name.value == 'flutter');
+      constraints[name.value as String] =
+          _parseVersionConstraint(constraint, _packageName, _FileType.pubspec,
+              // Flutter constraints get special treatment, as Flutter won't be
+              // using semantic versioning to mark breaking releases.
+              ignoreUpperBound: name.value == 'flutter');
     });
 
     return constraints;
@@ -280,10 +348,14 @@
   ///
   /// If [expectedName] is passed and the pubspec doesn't have a matching name
   /// field, this will throw a [PubspecException].
+  ///
+  /// If [allowOverridesFile] is `true` [pubspecOverridesFilename] is loaded and
+  /// is allowed to override dependency_overrides from `pubspec.yaml`.
   factory Pubspec.load(String packageDir, SourceRegistry sources,
-      {String? expectedName}) {
-    var pubspecPath = path.join(packageDir, 'pubspec.yaml');
-    var pubspecUri = path.toUri(pubspecPath);
+      {String? expectedName, bool allowOverridesFile = false}) {
+    var pubspecPath = path.join(packageDir, pubspecYamlFilename);
+    var overridesPath = path.join(packageDir, pubspecOverridesFilename);
+
     if (!fileExists(pubspecPath)) {
       throw FileException(
           // Make the package dir absolute because for the entrypoint it'll just
@@ -292,20 +364,31 @@
           '"${canonicalize(packageDir)}".',
           pubspecPath);
     }
+    String? overridesFileContents =
+        allowOverridesFile && fileExists(overridesPath)
+            ? readTextFile(overridesPath)
+            : null;
 
-    return Pubspec.parse(readTextFile(pubspecPath), sources,
-        expectedName: expectedName, location: pubspecUri);
+    return Pubspec.parse(
+      readTextFile(pubspecPath),
+      sources,
+      expectedName: expectedName,
+      location: path.toUri(pubspecPath),
+      overridesFileContents: overridesFileContents,
+      overridesLocation: path.toUri(overridesPath),
+    );
   }
 
-  Pubspec(String name,
-      {Version? version,
-      Iterable<PackageRange>? dependencies,
-      Iterable<PackageRange>? devDependencies,
-      Iterable<PackageRange>? dependencyOverrides,
-      Map? fields,
-      SourceRegistry? sources,
-      Map<String, VersionConstraint>? sdkConstraints})
-      : _dependencies = dependencies == null
+  Pubspec(
+    String name, {
+    Version? version,
+    Iterable<PackageRange>? dependencies,
+    Iterable<PackageRange>? devDependencies,
+    Iterable<PackageRange>? dependencyOverrides,
+    Map? fields,
+    SourceRegistry? sources,
+    Map<String, VersionConstraint>? sdkConstraints,
+  })  : _dependencies = dependencies == null
             ? null
             : Map.fromIterable(dependencies, key: (range) => range.name),
         _devDependencies = devDependencies == null
@@ -317,22 +400,13 @@
         _sdkConstraints = sdkConstraints ??
             UnmodifiableMapView({'dart': VersionConstraint.any}),
         _includeDefaultSdkConstraint = false,
-        _sources = sources,
+        _sources = sources ?? SourceRegistry(),
+        _overridesFileFields = null,
         super(
           fields == null ? YamlMap() : YamlMap.wrap(fields),
           name: name,
           version: version,
         );
-  Pubspec.empty()
-      : _sources = null,
-        _dependencies = {},
-        _devDependencies = {},
-        _sdkConstraints = {'dart': VersionConstraint.any},
-        _includeDefaultSdkConstraint = false,
-        super(
-          YamlMap(),
-          version: Version.none,
-        );
 
   /// Returns a Pubspec object for an already-parsed map representing its
   /// contents.
@@ -342,8 +416,9 @@
   ///
   /// [location] is the location from which this pubspec was loaded.
   Pubspec.fromMap(Map fields, this._sources,
-      {String? expectedName, Uri? location})
-      : _includeDefaultSdkConstraint = true,
+      {YamlMap? overridesFields, String? expectedName, Uri? location})
+      : _overridesFileFields = overridesFields,
+        _includeDefaultSdkConstraint = true,
         super(fields is YamlMap
             ? fields
             : YamlMap.wrap(fields, sourceUrl: location)) {
@@ -358,31 +433,49 @@
         this.fields.nodes['name']!.span);
   }
 
-  /// Parses the pubspec stored at [filePath] whose text is [contents].
+  /// Parses the pubspec stored at [location] whose text is [contents].
   ///
   /// If the pubspec doesn't define a version for itself, it defaults to
   /// [Version.none].
-  factory Pubspec.parse(String contents, SourceRegistry sources,
-      {String? expectedName, Uri? location}) {
-    YamlNode pubspecNode;
+  factory Pubspec.parse(
+    String contents,
+    SourceRegistry sources, {
+    String? expectedName,
+    Uri? location,
+    String? overridesFileContents,
+    Uri? overridesLocation,
+  }) {
+    late final YamlMap pubspecMap;
+    YamlMap? overridesFileMap;
     try {
-      pubspecNode = loadYamlNode(contents, sourceUrl: location);
+      pubspecMap = _ensureMap(loadYamlNode(contents, sourceUrl: location));
+      if (overridesFileContents != null) {
+        overridesFileMap = _ensureMap(
+            loadYamlNode(overridesFileContents, sourceUrl: overridesLocation));
+      }
     } on YamlException catch (error) {
       throw PubspecException(error.message, error.span);
     }
 
-    Map pubspecMap;
-    if (pubspecNode is YamlScalar && pubspecNode.value == null) {
-      pubspecMap = YamlMap(sourceUrl: location);
-    } else if (pubspecNode is YamlMap) {
-      pubspecMap = pubspecNode;
-    } else {
-      throw PubspecException(
-          'The pubspec must be a YAML mapping.', pubspecNode.span);
-    }
-
     return Pubspec.fromMap(pubspecMap, sources,
-        expectedName: expectedName, location: location);
+        overridesFields: overridesFileMap,
+        expectedName: expectedName,
+        location: location);
+  }
+
+  /// Ensures that [node] is a mapping.
+  ///
+  /// If [node] is already a map it is returned.
+  /// If [node] is yaml-null an empty map is returned.
+  /// Otherwise an exception is thrown.
+  static YamlMap _ensureMap(YamlNode node) {
+    if (node is YamlScalar && node.value == null) {
+      return YamlMap(sourceUrl: node.span.sourceUrl);
+    } else if (node is YamlMap) {
+      return node;
+    } else {
+      throw PubspecException('The pubspec must be a YAML mapping.', node.span);
+    }
   }
 
   /// Returns a list of most errors in this pubspec.
@@ -409,29 +502,39 @@
     _collectError(_ensureEnvironment);
     return errors;
   }
+}
 
-  /// Parses the dependency field named [field], and returns the corresponding
-  /// map of dependency names to dependencies.
-  Map<String, PackageRange> _parseDependencies(String field, YamlNode? node) {
-    var dependencies = <String, PackageRange>{};
+/// Parses the dependency field named [field], and returns the corresponding
+/// map of dependency names to dependencies.
+Map<String, PackageRange> _parseDependencies(
+  String field,
+  YamlNode? node,
+  SourceRegistry sources,
+  LanguageVersion languageVersion,
+  String? packageName,
+  Uri? location, {
+  _FileType fileType = _FileType.pubspec,
+}) {
+  var dependencies = <String, PackageRange>{};
 
-    // Allow an empty dependencies key.
-    if (node == null || node.value == null) return dependencies;
+  // Allow an empty dependencies key.
+  if (node == null || node.value == null) return dependencies;
 
-    if (node is! YamlMap) {
-      _error('"$field" field must be a map.', node.span);
-    }
+  if (node is! YamlMap) {
+    _error('"$field" field must be a map.', node.span);
+  }
 
-    var nonStringNode = node.nodes.keys
-        .firstWhere((e) => e.value is! String, orElse: () => null);
-    if (nonStringNode != null) {
-      _error('A dependency name must be a string.', nonStringNode.span);
-    }
+  var nonStringNode =
+      node.nodes.keys.firstWhere((e) => e.value is! String, orElse: () => null);
+  if (nonStringNode != null) {
+    _error('A dependency name must be a string.', nonStringNode.span);
+  }
 
-    node.nodes.forEach((nameNode, specNode) {
+  node.nodes.forEach(
+    (nameNode, specNode) {
       var name = nameNode.value;
       var spec = specNode.value;
-      if (fields['name'] != null && name == this.name) {
+      if (packageName != null && name == packageName) {
         _error('A package may not list itself as a dependency.', nameNode.span);
       }
 
@@ -441,10 +544,11 @@
       VersionConstraint versionConstraint = VersionRange();
       var features = const <String, FeatureDependency>{};
       if (spec == null) {
-        sourceName = _sources!.defaultSource.name;
+        sourceName = sources.defaultSource.name;
       } else if (spec is String) {
-        sourceName = _sources!.defaultSource.name;
-        versionConstraint = _parseVersionConstraint(specNode);
+        sourceName = sources.defaultSource.name;
+        versionConstraint =
+            _parseVersionConstraint(specNode, packageName, fileType);
       } else if (spec is Map) {
         // Don't write to the immutable YAML map.
         spec = Map.from(spec);
@@ -452,7 +556,11 @@
 
         if (spec.containsKey('version')) {
           spec.remove('version');
-          versionConstraint = _parseVersionConstraint(specMap.nodes['version']);
+          versionConstraint = _parseVersionConstraint(
+            specMap.nodes['version'],
+            packageName,
+            fileType,
+          );
         }
 
         if (spec.containsKey('features')) {
@@ -483,151 +591,82 @@
       // Let the source validate the description.
       var ref = _wrapFormatException('description', descriptionNode?.span, () {
         String? pubspecPath;
-        var location = _location;
         if (location != null && _isFileUri(location)) {
-          pubspecPath = path.fromUri(_location);
+          pubspecPath = path.fromUri(location);
         }
 
-        return _sources![sourceName]!.parseRef(
+        return sources[sourceName]!.parseRef(
           name,
           descriptionNode?.value,
           containingPath: pubspecPath,
           languageVersion: languageVersion,
         );
-      }, targetPackage: name);
+      }, packageName, fileType, targetPackage: name);
 
       dependencies[name] =
           ref.withConstraint(versionConstraint).withFeatures(features);
-    });
+    },
+  );
 
-    return dependencies;
+  return dependencies;
+}
+
+/// Parses [node] to a map from feature names to whether those features are
+/// enabled.
+Map<String, FeatureDependency> _parseDependencyFeatures(YamlNode? node) {
+  if (node?.value == null) return const {};
+  if (node is! YamlMap) _error('Features must be a map.', node!.span);
+
+  return mapMap(node.nodes,
+      key: (dynamic nameNode, dynamic _) => _validateFeatureName(nameNode),
+      value: (dynamic _, dynamic valueNode) {
+        var value = valueNode.value;
+        if (value is bool) {
+          return value ? FeatureDependency.required : FeatureDependency.unused;
+        } else if (value is String && value == 'if available') {
+          return FeatureDependency.ifAvailable;
+        } else {
+          _error('Features must be true, false, or "if available".',
+              valueNode.span);
+        }
+      });
+}
+
+/// Verifies that [node] is a string and a valid feature name, and returns it
+/// if so.
+String _validateFeatureName(YamlNode node) {
+  var name = node.value;
+  if (name is! String) {
+    _error('A feature name must be a string.', node.span);
+  } else if (!packageNameRegExp.hasMatch(name)) {
+    _error('A feature name must be a valid Dart identifier.', node.span);
   }
 
-  /// Parses [node] to a [VersionConstraint].
-  ///
-  /// If or [defaultUpperBoundConstraint] is specified then it will be set as
-  /// the max constraint if the original constraint doesn't have an upper
-  /// bound and it is compatible with [defaultUpperBoundConstraint].
-  ///
-  /// If [ignoreUpperBound] the max constraint is ignored.
-  VersionConstraint _parseVersionConstraint(YamlNode? node,
-      {VersionConstraint? defaultUpperBoundConstraint,
-      bool ignoreUpperBound = false}) {
-    if (node?.value == null) {
-      return defaultUpperBoundConstraint ?? VersionConstraint.any;
-    }
-    if (node!.value is! String) {
-      _error('A version constraint must be a string.', node.span);
-    }
+  return name;
+}
 
-    return _wrapFormatException('version constraint', node.span, () {
-      var constraint = VersionConstraint.parse(node.value);
-      if (defaultUpperBoundConstraint != null &&
-          constraint is VersionRange &&
-          constraint.max == null &&
-          defaultUpperBoundConstraint.allowsAny(constraint)) {
-        constraint = VersionConstraint.intersection(
-            [constraint, defaultUpperBoundConstraint]);
-      }
-      if (ignoreUpperBound && constraint is VersionRange) {
-        return VersionRange(
-            min: constraint.min, includeMin: constraint.includeMin);
-      }
-      return constraint;
-    });
-  }
-
-  /// Parses [node] to a map from feature names to whether those features are
-  /// enabled.
-  Map<String, FeatureDependency> _parseDependencyFeatures(YamlNode? node) {
-    if (node?.value == null) return const {};
-    if (node is! YamlMap) _error('Features must be a map.', node!.span);
-
-    return mapMap(node.nodes,
-        key: (dynamic nameNode, dynamic _) => _validateFeatureName(nameNode),
-        value: (dynamic _, dynamic valueNode) {
-          var value = valueNode.value;
-          if (value is bool) {
-            return value
-                ? FeatureDependency.required
-                : FeatureDependency.unused;
-          } else if (value is String && value == 'if available') {
-            return FeatureDependency.ifAvailable;
-          } else {
-            _error('Features must be true, false, or "if available".',
-                valueNode.span);
-          }
-        });
-  }
-
-  /// Verifies that [node] is a string and a valid feature name, and returns it
-  /// if so.
-  String _validateFeatureName(YamlNode node) {
-    var name = node.value;
-    if (name is! String) {
-      _error('A feature name must be a string.', node.span);
-    } else if (!packageNameRegExp.hasMatch(name)) {
-      _error('A feature name must be a valid Dart identifier.', node.span);
-    }
-
-    return name;
-  }
-
-  /// Verifies that [node] is a list of strings and returns it.
-  ///
-  /// If [validate] is passed, it's called for each string in [node].
-  List<String> _parseStringList(YamlNode? node,
-      {void Function(String value, SourceSpan)? validate}) {
-    var list = _parseList(node);
-    for (var element in list.nodes) {
-      var value = element.value;
-      if (value is String) {
-        if (validate != null) validate(value, element.span);
-      } else {
-        _error('Must be a string.', element.span);
-      }
-    }
-    return list.cast<String>();
-  }
-
-  /// Verifies that [node] is a list and returns it.
-  YamlList _parseList(YamlNode? node) {
-    if (node == null || node.value == null) return YamlList();
-    if (node is YamlList) return node;
-    _error('Must be a list.', node.span);
-  }
-
-  /// Runs [fn] and wraps any [FormatException] it throws in a
-  /// [PubspecException].
-  ///
-  /// [description] should be a noun phrase that describes whatever's being
-  /// parsed or processed by [fn]. [span] should be the location of whatever's
-  /// being processed within the pubspec.
-  ///
-  /// If [targetPackage] is provided, the value is used to describe the
-  /// dependency that caused the problem.
-  T _wrapFormatException<T>(
-      String description, SourceSpan? span, T Function() fn,
-      {String? targetPackage}) {
-    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';
-      }
-      msg = '$msg: ${e.message}';
-      _error(msg, span);
+/// Verifies that [node] is a list of strings and returns it.
+///
+/// If [validate] is passed, it's called for each string in [node].
+List<String> _parseStringList(YamlNode? node,
+    {void Function(String value, SourceSpan)? validate}) {
+  var list = _parseList(node);
+  for (var element in list.nodes) {
+    var value = element.value;
+    if (value is String) {
+      if (validate != null) validate(value, element.span);
+    } else {
+      _error('Must be a string.', element.span);
     }
   }
+  return list.cast<String>();
+}
 
-  /// Throws a [PubspecException] with the given message.
-  Never _error(String message, SourceSpan? span) {
-    throw PubspecException(message, span);
-  }
+/// Verifies that [node] is a list and returns it.
+YamlList _parseList(YamlNode? node) {
+  if (node == null || node.value == null) return YamlList();
+  if (node is YamlList) return node;
+  _error('Must be a list.', node.span);
 }
 
 /// Returns whether [uri] is a file URI.
@@ -635,3 +674,86 @@
 /// This is slightly more complicated than just checking if the scheme is
 /// 'file', since relative URIs also refer to the filesystem on the VM.
 bool _isFileUri(Uri uri) => uri.scheme == 'file' || uri.scheme == '';
+
+/// Parses [node] to a [VersionConstraint].
+///
+/// If or [defaultUpperBoundConstraint] is specified then it will be set as
+/// the max constraint if the original constraint doesn't have an upper
+/// bound and it is compatible with [defaultUpperBoundConstraint].
+///
+/// If [ignoreUpperBound] the max constraint is ignored.
+VersionConstraint _parseVersionConstraint(
+    YamlNode? node, String? packageName, _FileType fileType,
+    {VersionConstraint? defaultUpperBoundConstraint,
+    bool ignoreUpperBound = false}) {
+  if (node?.value == null) {
+    return defaultUpperBoundConstraint ?? VersionConstraint.any;
+  }
+  if (node!.value is! String) {
+    _error('A version constraint must be a string.', node.span);
+  }
+
+  return _wrapFormatException('version constraint', node.span, () {
+    var constraint = VersionConstraint.parse(node.value);
+    if (defaultUpperBoundConstraint != null &&
+        constraint is VersionRange &&
+        constraint.max == null &&
+        defaultUpperBoundConstraint.allowsAny(constraint)) {
+      constraint = VersionConstraint.intersection(
+          [constraint, defaultUpperBoundConstraint]);
+    }
+    if (ignoreUpperBound && constraint is VersionRange) {
+      return VersionRange(
+          min: constraint.min, includeMin: constraint.includeMin);
+    }
+    return constraint;
+  }, packageName, fileType);
+}
+
+/// Runs [fn] and wraps any [FormatException] it throws in a
+/// [PubspecException].
+///
+/// [description] should be a noun phrase that describes whatever's being
+/// parsed or processed by [fn]. [span] should be the location of whatever's
+/// being processed within the pubspec.
+///
+/// If [targetPackage] is provided, the value is used to describe the
+/// dependency that caused the problem.
+T _wrapFormatException<T>(String description, SourceSpan? span, T Function() fn,
+    String? packageName, _FileType fileType,
+    {String? targetPackage}) {
+  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';
+    final typeName = _fileTypeName(fileType);
+    if (targetPackage != null) {
+      msg = '$msg in the "$packageName" $typeName on the "$targetPackage" '
+          'dependency';
+    }
+    msg = '$msg: ${e.message}';
+    _error(msg, span);
+  }
+}
+
+/// Throws a [PubspecException] with the given message.
+Never _error(String message, SourceSpan? span) {
+  throw PubspecException(message, span);
+}
+
+enum _FileType {
+  pubspec,
+  pubspecOverrides,
+}
+
+String _fileTypeName(_FileType type) {
+  switch (type) {
+    case _FileType.pubspec:
+      return 'pubspec';
+    case _FileType.pubspecOverrides:
+      return 'pubspec override';
+  }
+}