Cleaner pubspec.lock parsing (#3487)
Avoids crashes when lockfile parsing fails.
diff --git a/lib/src/command.dart b/lib/src/command.dart
index e86dd50..4a65fb1 100644
--- a/lib/src/command.dart
+++ b/lib/src/command.dart
@@ -194,7 +194,6 @@
return exit_codes.SUCCESS;
} catch (error, chain) {
log.exception(error, chain);
-
if (_pubTopLevel.trace) {
log.dumpTranscriptToStdErr();
} else if (!isUserFacingException(error)) {
diff --git a/lib/src/entrypoint.dart b/lib/src/entrypoint.dart
index b536cca..36f8047 100644
--- a/lib/src/entrypoint.dart
+++ b/lib/src/entrypoint.dart
@@ -12,6 +12,7 @@
import 'package:path/path.dart' as p;
import 'package:pool/pool.dart';
import 'package:pub_semver/pub_semver.dart';
+import 'package:source_span/source_span.dart';
import 'package:yaml/yaml.dart';
import 'command_runner.dart';
@@ -101,7 +102,17 @@
if (!fileExists(lockFilePath)) {
return _lockFile = LockFile.empty();
} else {
- return _lockFile = LockFile.load(lockFilePath, cache.sources);
+ try {
+ return _lockFile = LockFile.load(lockFilePath, cache.sources);
+ } on SourceSpanException catch (e) {
+ throw SourceSpanApplicationException(
+ e.message,
+ e.span,
+ explanation: 'Failed parsing lock file:',
+ hint:
+ 'Consider deleting the file and running `$topLevelProgram pub get` to recreate it.',
+ );
+ }
}
}
@@ -898,7 +909,7 @@
final keyNode = environment.nodes.entries
.firstWhere((e) => (e.key as YamlNode).value == sdk)
.key as YamlNode;
- throw PubspecException('''
+ throw SourceSpanApplicationException('''
$pubspecPath refers to an unknown sdk '$sdk'.
Did you mean to add it as a dependency?
diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart
index ec9f876..39e9c51 100644
--- a/lib/src/exceptions.dart
+++ b/lib/src/exceptions.dart
@@ -7,6 +7,7 @@
import 'package:args/command_runner.dart';
import 'package:http/http.dart' as http;
+import 'package:source_span/source_span.dart';
import 'package:stack_trace/stack_trace.dart';
import 'package:yaml/yaml.dart';
@@ -127,3 +128,34 @@
error is YamlException ||
error is UsageException;
}
+
+/// An exception thrown when parsing a `pubspec.yaml` or a `pubspec.lock`.
+///
+/// These exceptions are often thrown lazily while accessing pubspec properties.
+///
+/// By being an [ApplicationException] this will not trigger a stack-trace on
+/// normal operations.
+///
+/// Works as a [SourceSpanFormatException], but can contain more context:
+/// An optional [explanation] that explains the operation that failed.
+/// An optional [hint] that gives suggestions how to proceed.
+class SourceSpanApplicationException extends SourceSpanFormatException
+ implements ApplicationException {
+ final String? explanation;
+ final String? hint;
+
+ SourceSpanApplicationException(String message, SourceSpan? span,
+ {this.hint, this.explanation})
+ : super(message, span);
+
+ @override
+ String toString({color}) {
+ return [
+ if (explanation != null) explanation,
+ span == null
+ ? message
+ : 'Error on ${span?.message(message, color: color)}',
+ if (hint != null) hint,
+ ].join('\n\n');
+ }
+}
diff --git a/lib/src/lock_file.dart b/lib/src/lock_file.dart
index 46be2bf..f133172 100644
--- a/lib/src/lock_file.dart
+++ b/lib/src/lock_file.dart
@@ -98,50 +98,54 @@
Uri? sourceUrl;
if (filePath != null) sourceUrl = p.toUri(filePath);
- var parsed = loadYamlNode(contents, sourceUrl: sourceUrl);
+ final parsed = _parseNode<YamlMap>(
+ loadYamlNode(contents, sourceUrl: sourceUrl),
+ 'YAML mapping',
+ );
- _validate(parsed is Map, 'The lockfile must be a YAML mapping.', parsed);
- var parsedMap = parsed as YamlMap;
-
- var sdkConstraints = <String, VersionConstraint>{};
- var sdkNode = parsedMap.nodes['sdk'];
+ final sdkConstraints = <String, VersionConstraint>{};
+ final sdkNode =
+ _getEntry<YamlScalar?>(parsed, 'sdk', 'string', required: false);
if (sdkNode != null) {
// Lockfiles produced by pub versions from 1.14.0 through 1.18.0 included
// a top-level "sdk" field which encoded the unified constraint on the
// Dart SDK. They had no way of specifying constraints on other SDKs.
sdkConstraints['dart'] = _parseVersionConstraint(sdkNode);
- } else if (parsedMap.containsKey('sdks')) {
- var sdksField = parsedMap['sdks'];
- _validate(sdksField is Map, 'The "sdks" field must be a mapping.',
- parsedMap.nodes['sdks']);
-
- sdksField.nodes.forEach((name, constraint) {
- _validate(name.value is String, 'SDK names must be strings.', name);
- sdkConstraints[name.value as String] =
- _parseVersionConstraint(constraint);
- });
}
- var packages = <String, PackageId>{};
- var packageEntries = parsedMap['packages'];
- if (packageEntries != null) {
- _validate(packageEntries is Map, 'The "packages" field must be a map.',
- parsedMap.nodes['packages']);
+ final sdksField =
+ _getEntry<YamlMap?>(parsed, 'sdks', 'map', required: false);
- packageEntries.forEach((name, spec) {
+ if (sdksField != null) {
+ _parseEachEntry<String, YamlScalar>(sdksField, (name, constraint) {
+ sdkConstraints[name] = _parseVersionConstraint(constraint);
+ }, 'string', 'string');
+ }
+
+ final packages = <String, PackageId>{};
+
+ final mainDependencies = <String>{};
+ final devDependencies = <String>{};
+ final overriddenDependencies = <String>{};
+
+ final packageEntries =
+ _getEntry<YamlMap?>(parsed, 'packages', 'map', required: false);
+
+ if (packageEntries != null) {
+ _parseEachEntry<String, YamlMap>(packageEntries, (name, spec) {
// Parse the version.
- _validate(spec.containsKey('version'),
- 'Package $name is missing a version.', spec);
- var version = Version.parse(spec['version']);
+ final versionEntry = _getStringEntry(spec, 'version');
+ final version = Version.parse(versionEntry);
// Parse the source.
- _validate(spec.containsKey('source'),
- 'Package $name is missing a source.', spec);
- var sourceName = spec['source'];
+ final sourceName = _getStringEntry(spec, 'source');
- _validate(spec.containsKey('description'),
- 'Package $name is missing a description.', spec);
- var description = spec['description'];
+ var descriptionNode =
+ _getEntry<YamlNode>(spec, 'description', 'description');
+
+ dynamic description = descriptionNode is YamlScalar
+ ? descriptionNode.value
+ : descriptionNode;
// Let the source parse the description.
var source = sources(sourceName);
@@ -150,18 +154,30 @@
id = source.parseId(name, version, description,
containingDir: filePath == null ? null : p.dirname(filePath));
} on FormatException catch (ex) {
- throw SourceSpanFormatException(
- ex.message, spec.nodes['description'].span);
+ _failAt(ex.message, spec.nodes['description']!);
}
// Validate the name.
- _validate(name == id.name,
- "Package name $name doesn't match ${id.name}.", spec);
+ if (name != id.name) {
+ _failAt("Package name $name doesn't match ${id.name}.", spec);
+ }
packages[name] = id;
- });
+ if (spec.containsKey('dependency')) {
+ final dependencyKind = _getStringEntry(spec, 'dependency');
+ switch (dependencyKind) {
+ case _directMain:
+ mainDependencies.add(name);
+ break;
+ case _directDev:
+ devDependencies.add(name);
+ break;
+ case _directOverridden:
+ overriddenDependencies.add(name);
+ }
+ }
+ }, 'string', 'map');
}
-
return LockFile._(
packages,
sdkConstraints,
@@ -170,15 +186,6 @@
const UnmodifiableSetView.empty());
}
- /// Asserts that [node] is a version constraint, and parses it.
- static VersionConstraint _parseVersionConstraint(YamlNode node) {
- _validate(node.value is String,
- 'Invalid version constraint: must be a string.', node);
-
- return _wrapFormatException('version constraint', node.span,
- () => VersionConstraint.parse(node.value));
- }
-
/// Runs [fn] and wraps any [FormatException] it throws in a
/// [SourceSpanFormatException].
///
@@ -195,10 +202,68 @@
}
}
- /// If [condition] is `false` throws a format error with [message] for [node].
- static void _validate(bool condition, String message, YamlNode? node) {
- if (condition) return;
- throw SourceSpanFormatException(message, node!.span);
+ static VersionConstraint _parseVersionConstraint(YamlNode node) {
+ return _parseNode(node, 'version constraint',
+ parse: VersionConstraint.parse);
+ }
+
+ static String _getStringEntry(YamlMap map, String key) {
+ return _parseNode<String>(
+ _getEntry<YamlScalar>(map, key, 'string'), 'string');
+ }
+
+ static T _parseNode<T>(YamlNode node, String typeDescription,
+ {T Function(String)? parse}) {
+ if (node is T) {
+ return node as T;
+ } else if (node is YamlScalar) {
+ final value = node.value;
+ if (parse != null) {
+ if (value is! String) {
+ _failAt('Expected a $typeDescription.', node);
+ }
+ return _wrapFormatException(
+ 'Expected a $typeDescription.', node.span, () => parse(node.value));
+ } else if (value is T) {
+ return value;
+ }
+ _failAt('Expected a $typeDescription.', node);
+ }
+ _failAt('Expected a $typeDescription.', node);
+ }
+
+ static void _parseEachEntry<K, V>(
+ YamlMap map,
+ void Function(K key, V value) f,
+ String keyTypeDescription,
+ String valueTypeDescription) {
+ map.nodes.forEach((key, value) {
+ f(_parseNode(key, keyTypeDescription),
+ _parseNode(value, valueTypeDescription));
+ });
+ }
+
+ static T _getEntry<T>(
+ YamlMap map,
+ String key,
+ String type, {
+ bool required = true,
+ }) {
+ final entry = map.nodes[key];
+ // `null` here always means not present. A value explicitly mapped to `null`
+ // would be a `YamlScalar(null)`.
+ if (entry == null) {
+ if (required) {
+ _failAt('Expected a `$key` entry.', map);
+ } else {
+ return null as T;
+ }
+ }
+ return _parseNode(entry, type);
+ }
+
+ static Never _failAt(String message, YamlNode node) {
+ throw SourceSpanFormatException(message, node.span);
}
/// Returns a copy of this LockFile with a package named [name] removed.
@@ -304,17 +369,22 @@
''';
}
+ static const _directMain = 'direct main';
+ static const _directDev = 'direct dev';
+ static const _directOverridden = 'direct overridden';
+ static const _transitive = 'transitive';
+
/// Returns the dependency classification for [package].
String _dependencyType(String package) {
- if (_mainDependencies.contains(package)) return 'direct main';
- if (_devDependencies.contains(package)) return 'direct dev';
+ if (_mainDependencies.contains(package)) return _directMain;
+ if (_devDependencies.contains(package)) return _directDev;
// If a package appears in `dependency_overrides` and another dependency
// section, the main section it appears in takes precedence.
if (_overriddenDependencies.contains(package)) {
- return 'direct overridden';
+ return _directOverridden;
}
- return 'transitive';
+ return _transitive;
}
/// `true` if [other] has the same packages as `this` in the same versions
diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart
index 0b6cd5b..6e1d566 100644
--- a/lib/src/pubspec.dart
+++ b/lib/src/pubspec.dart
@@ -133,7 +133,7 @@
if (pubspecOverridesFields != null) {
pubspecOverridesFields.nodes.forEach((key, _) {
if (!const {'dependency_overrides'}.contains(key.value)) {
- throw PubspecException(
+ throw SourceSpanApplicationException(
'pubspec_overrides.yaml only supports the `dependency_overrides` field.',
key.span,
);
@@ -293,7 +293,7 @@
/// Loads the pubspec for a package located in [packageDir].
///
/// If [expectedName] is passed and the pubspec doesn't have a matching name
- /// field, this will throw a [PubspecException].
+ /// field, this will throw a [SourceSpanApplicationException].
///
/// If [allowOverridesFile] is `true` [pubspecOverridesFilename] is loaded and
/// is allowed to override dependency_overrides from `pubspec.yaml`.
@@ -373,7 +373,7 @@
if (expectedName == null) return;
if (name == expectedName) return;
- throw PubspecException(
+ throw SourceSpanApplicationException(
'"name" field doesn\'t match expected name '
'"$expectedName".',
this.fields.nodes['name']!.span);
@@ -400,7 +400,7 @@
loadYamlNode(overridesFileContents, sourceUrl: overridesLocation));
}
} on YamlException catch (error) {
- throw PubspecException(error.message, error.span);
+ throw SourceSpanApplicationException(error.message, error.span);
}
return Pubspec.fromMap(pubspecMap, sources,
@@ -420,19 +420,20 @@
} else if (node is YamlMap) {
return node;
} else {
- throw PubspecException('The pubspec must be a YAML mapping.', node.span);
+ throw SourceSpanApplicationException(
+ 'The pubspec must be a YAML mapping.', node.span);
}
}
/// Returns a list of most errors in this pubspec.
///
/// This will return at most one error for each field.
- List<PubspecException> get allErrors {
- var errors = <PubspecException>[];
+ List<SourceSpanApplicationException> get allErrors {
+ var errors = <SourceSpanApplicationException>[];
void collectError(void Function() fn) {
try {
fn();
- } on PubspecException catch (e) {
+ } on SourceSpanApplicationException catch (e) {
errors.add(e);
}
}
@@ -591,7 +592,7 @@
}
/// Runs [fn] and wraps any [FormatException] it throws in a
-/// [PubspecException].
+/// [SourceSpanApplicationException].
///
/// [description] should be a noun phrase that describes whatever's being
/// parsed or processed by [fn]. [span] should be the location of whatever's
@@ -611,7 +612,7 @@
return fn();
} on FormatException catch (e) {
// If we already have a pub exception with a span, re-use that
- if (e is PubspecException) rethrow;
+ if (e is SourceSpanApplicationException) rethrow;
var msg = 'Invalid $description';
final typeName = _fileTypeName(fileType);
@@ -624,9 +625,9 @@
}
}
-/// Throws a [PubspecException] with the given message.
+/// Throws a [SourceSpanApplicationException] with the given message.
Never _error(String message, SourceSpan? span) {
- throw PubspecException(message, span);
+ throw SourceSpanApplicationException(message, span);
}
enum _FileType {
diff --git a/lib/src/pubspec_parse.dart b/lib/src/pubspec_parse.dart
index c86a706..c6babfb 100644
--- a/lib/src/pubspec_parse.dart
+++ b/lib/src/pubspec_parse.dart
@@ -7,7 +7,7 @@
import 'package:source_span/source_span.dart';
import 'package:yaml/yaml.dart';
-import 'exceptions.dart' show ApplicationException;
+import 'exceptions.dart';
import 'utils.dart' show identifierRegExp, reservedWords;
/// A regular expression matching allowed package names.
@@ -42,15 +42,18 @@
String _lookupName() {
final name = fields['name'];
if (name == null) {
- throw PubspecException('Missing the required "name" field.', fields.span);
+ throw SourceSpanApplicationException(
+ 'Missing the required "name" field.', fields.span);
} else if (name is! String) {
- throw PubspecException(
+ throw SourceSpanApplicationException(
'"name" field must be a string.', fields.nodes['name']?.span);
} else if (!packageNameRegExp.hasMatch(name)) {
- throw PubspecException('"name" field must be a valid Dart identifier.',
+ throw SourceSpanApplicationException(
+ '"name" field must be a valid Dart identifier.',
fields.nodes['name']?.span);
} else if (reservedWords.contains(name)) {
- throw PubspecException('"name" field may not be a Dart reserved word.',
+ throw SourceSpanApplicationException(
+ '"name" field may not be a Dart reserved word.',
fields.nodes['name']?.span);
}
@@ -223,7 +226,7 @@
bool get isPrivate => publishTo == 'none';
/// Runs [fn] and wraps any [FormatException] it throws in a
- /// [PubspecException].
+ /// [SourceSpanApplicationException].
///
/// [description] should be a noun phrase that describes whatever's being
/// parsed or processed by [fn]. [span] should be the location of whatever's
@@ -242,21 +245,13 @@
msg = '$msg in the "$name" pubspec on the "$targetPackage" dependency';
}
msg = '$msg: ${e.message}';
- throw PubspecException(msg, span);
+ throw SourceSpanApplicationException(msg, span);
}
}
- /// Throws a [PubspecException] with the given message.
+ /// Throws a [SourceSpanApplicationException] with the given message.
@alwaysThrows
void _error(String message, SourceSpan? span) {
- throw PubspecException(message, span);
+ throw SourceSpanApplicationException(message, span);
}
}
-
-/// An exception thrown when parsing a pubspec.
-///
-/// These exceptions are often thrown lazily while accessing pubspec properties.
-class PubspecException extends SourceSpanFormatException
- implements ApplicationException {
- PubspecException(String message, SourceSpan? span) : super(message, span);
-}
diff --git a/lib/src/solver/package_lister.dart b/lib/src/solver/package_lister.dart
index ce2b4c1..1597e73 100644
--- a/lib/src/solver/package_lister.dart
+++ b/lib/src/solver/package_lister.dart
@@ -190,7 +190,7 @@
try {
pubspec = await withDependencyType(
_dependencyType, () => _systemCache.describe(id));
- } on PubspecException catch (error) {
+ } on SourceSpanApplicationException catch (error) {
// The lockfile for the pubspec couldn't be parsed,
log.fine('Failed to parse pubspec for $id:\n$error');
_knownInvalidVersions = _knownInvalidVersions.union(id.version);
diff --git a/test/list_package_dirs/lockfile_error_test.dart b/test/list_package_dirs/lockfile_error_test.dart
index f4440ec..5c7bff9 100644
--- a/test/list_package_dirs/lockfile_error_test.dart
+++ b/test/list_package_dirs/lockfile_error_test.dart
@@ -19,7 +19,7 @@
'list-package-dirs',
'--format=json'
], outputJson: {
- 'error': contains('The lockfile must be a YAML mapping.'),
+ 'error': contains('Expected a YAML mapping.'),
'path': canonicalize(path.join(d.sandbox, appPath, 'pubspec.lock'))
}, exitCode: exit_codes.DATA);
});
diff --git a/test/pubspec_test.dart b/test/pubspec_test.dart
index 2ccb30f..1a9ac48 100644
--- a/test/pubspec_test.dart
+++ b/test/pubspec_test.dart
@@ -4,6 +4,7 @@
import 'dart:io';
+import 'package:pub/src/exceptions.dart';
import 'package:pub/src/pubspec.dart';
import 'package:pub/src/sdk.dart';
import 'package:pub/src/source/hosted.dart';
@@ -15,11 +16,12 @@
group('parse()', () {
final sources = SystemCache().sources;
- var throwsPubspecException = throwsA(const TypeMatcher<PubspecException>());
+ var throwsPubspecException =
+ throwsA(const TypeMatcher<SourceSpanApplicationException>());
void expectPubspecException(String contents, void Function(Pubspec) fn,
[String? expectedContains]) {
- var expectation = const TypeMatcher<PubspecException>();
+ var expectation = const TypeMatcher<SourceSpanApplicationException>();
if (expectedContains != null) {
expectation = expectation.having(
(error) => error.message, 'message', contains(expectedContains));
@@ -390,7 +392,7 @@
expect(
() => pubspec.dependencies,
throwsA(
- isA<PubspecException>()
+ isA<SourceSpanApplicationException>()
.having((e) => e.span!.text, 'span.text', 'invalid value'),
),
);
@@ -700,7 +702,7 @@
void Function(Pubspec) fn, [
String? expectedContains,
]) {
- var expectation = isA<PubspecException>();
+ var expectation = isA<SourceSpanApplicationException>();
if (expectedContains != null) {
expectation = expectation.having((error) => error.toString(),
'toString()', contains(expectedContains));