blob: bdafe4e80d9748bb82ff7ebdc14db39825dea0b4 [file] [log] [blame]
// Copyright (c) 2012, 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:collection/collection.dart' hide mapMap;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:source_span/source_span.dart';
import 'package:yaml/yaml.dart';
import 'io.dart';
import 'language_version.dart';
import 'package_config.dart';
import 'package_name.dart';
import 'sdk.dart' show sdk;
import 'system_cache.dart';
import 'utils.dart';
/// A parsed and validated `pubspec.lock` file.
class LockFile {
/// The packages this lockfile pins.
final Map<String, PackageId> packages;
/// The intersections of all SDK constraints for all locked packages, indexed
/// by SDK identifier.
Map<String, VersionConstraint> sdkConstraints;
/// Dependency names that appeared in the root package's `dependencies`
/// section.
final Set<String> mainDependencies;
/// Dependency names that appeared in the root package's `dev_dependencies`
/// section.
final Set<String> devDependencies;
/// Dependency names that appeared in the root package's
/// `dependency_overrides` section.
final Set<String> overriddenDependencies;
/// Creates a new lockfile containing [ids].
///
/// If passed, [mainDependencies], [devDependencies], and
/// [overriddenDependencies] indicate which dependencies should be marked as
/// being listed in the main package's `dependencies`, `dev_dependencies`, and
/// `dependency_overrides` sections, respectively. These are consumed by the
/// analysis server to provide better auto-completion.
LockFile(
Iterable<PackageId> ids, {
Map<String, VersionConstraint>? sdkConstraints,
Set<String>? mainDependencies,
Set<String>? devDependencies,
Set<String>? overriddenDependencies,
}) : this._(
Map.fromIterable(
ids.where((id) => !id.isRoot),
key: (id) => id.name,
),
sdkConstraints ?? {'dart': VersionConstraint.any},
mainDependencies ?? const UnmodifiableSetView.empty(),
devDependencies ?? const UnmodifiableSetView.empty(),
overriddenDependencies ?? const UnmodifiableSetView.empty(),
);
LockFile._(
Map<String, PackageId> packages,
this.sdkConstraints,
this.mainDependencies,
this.devDependencies,
this.overriddenDependencies,
) : packages = UnmodifiableMapView(packages);
LockFile.empty()
: packages = const {},
sdkConstraints = {'dart': VersionConstraint.any},
mainDependencies = const UnmodifiableSetView.empty(),
devDependencies = const UnmodifiableSetView.empty(),
overriddenDependencies = const UnmodifiableSetView.empty();
/// Loads a lockfile from [filePath].
factory LockFile.load(String filePath, SourceRegistry sources) {
return LockFile._parse(filePath, readTextFile(filePath), sources);
}
/// Parses a lockfile whose text is [contents].
///
/// If [filePath] is given, path-dependencies will be interpreted relative to
/// that.
factory LockFile.parse(
String contents,
SourceRegistry sources, {
String? filePath,
}) {
return LockFile._parse(filePath, contents, sources);
}
/// Parses the lockfile whose text is [contents].
///
/// [filePath] is the system-native path to the lockfile on disc. It may be
/// `null`.
static LockFile _parse(
String? filePath,
String contents,
SourceRegistry sources,
) {
if (contents.trim() == '') return LockFile.empty();
Uri? sourceUrl;
if (filePath != null) sourceUrl = p.toUri(filePath);
final parsed = _parseNode<YamlMap>(
loadYamlNode(contents, sourceUrl: sourceUrl),
'YAML mapping',
);
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);
}
final sdksField =
_getEntry<YamlMap?>(parsed, 'sdks', 'map', required: false);
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.
final versionEntry = _getStringEntry(spec, 'version');
final version = Version.parse(versionEntry);
// Parse the source.
final sourceName = _getStringEntry(spec, 'source');
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);
PackageId id;
try {
id = source.parseId(
name,
version,
description,
containingDir: filePath == null ? null : p.dirname(filePath),
);
} on FormatException catch (ex) {
_failAt(ex.message, spec.nodes['description']!);
}
// Validate the name.
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,
const UnmodifiableSetView.empty(),
const UnmodifiableSetView.empty(),
const UnmodifiableSetView.empty(),
);
}
/// Runs [fn] and wraps any [FormatException] it throws in a
/// [SourceSpanFormatException].
///
/// [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.
static T _wrapFormatException<T>(
String description,
SourceSpan span,
T Function() fn,
) {
try {
return fn();
} on FormatException catch (e) {
throw SourceSpanFormatException(
'Invalid $description: ${e.message}',
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.
///
/// Returns an identical [LockFile] if there's no package named [name].
LockFile removePackage(String name) {
if (!this.packages.containsKey(name)) return this;
var packages = Map<String, PackageId>.from(this.packages);
packages.remove(name);
return LockFile._(
packages,
sdkConstraints,
mainDependencies,
devDependencies,
overriddenDependencies,
);
}
/// Returns the contents of the `.dart_tool/package_config` file generated
/// from this lockfile.
///
/// This file will replace the `.packages` file.
///
/// If [entrypoint] is passed, an accompanying [entrypointSdkConstraint]
/// should be given, these identify the current package in which this file is
/// written. Passing `null` as [entrypointSdkConstraint] is correct if the
/// current package has no SDK constraint.
Future<String> packageConfigFile(
SystemCache cache, {
String? entrypoint,
VersionConstraint? entrypointSdkConstraint,
String? relativeFrom,
}) async {
final entries = <PackageConfigEntry>[];
for (final name in ordered(packages.keys)) {
final id = packages[name]!;
final rootPath = cache.getDirectory(id, relativeFrom: relativeFrom);
Uri rootUri;
if (p.isRelative(rootPath)) {
// Relative paths are relative to the root project, we want them
// relative to the `.dart_tool/package_config.json` file.
rootUri = p.toUri(p.join('..', rootPath));
} else {
rootUri = p.toUri(rootPath);
}
final pubspec = await cache.describe(id);
entries.add(
PackageConfigEntry(
name: name,
rootUri: rootUri,
packageUri: p.toUri('lib/'),
languageVersion: pubspec.languageVersion,
),
);
}
if (entrypoint != null) {
entries.add(
PackageConfigEntry(
name: entrypoint,
rootUri: p.toUri('../'),
packageUri: p.toUri('lib/'),
languageVersion: LanguageVersion.fromSdkConstraint(
entrypointSdkConstraint,
),
),
);
}
final packageConfig = PackageConfig(
configVersion: 2,
packages: entries,
generated: DateTime.now(),
generator: 'pub',
generatorVersion: sdk.version,
);
return '${JsonEncoder.withIndent(' ').convert(packageConfig.toJson())}\n';
}
/// Returns the serialized YAML text of the lock file.
///
/// [packageDir] is the containing directory of the root package, used to
/// serialize relative path package descriptions. If it is null, they will be
/// serialized as absolute.
String serialize(String? packageDir, SystemCache cache) {
// Convert the dependencies to a simple object.
var packageMap = {};
for (final id in packages.values) {
packageMap[id.name] = {
'version': id.version.toString(),
'source': id.source.name,
'description':
id.description.serializeForLockfile(containingDir: packageDir),
'dependency': _dependencyType(id.name)
};
}
var data = {
'sdks': mapMap(
sdkConstraints,
value: (_, constraint) => constraint.toString(),
),
'packages': packageMap
};
return '''
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
${yamlToString(data)}
''';
}
/// Saves the list of concrete package versions to [lockFilePath].
///
/// Will use Windows line endings (`\r\n`) if the file already exists, and
/// uses that.
///
/// Relative paths will be resolved relative to [lockFilePath]
void writeToFile(String lockFilePath, SystemCache cache) {
final windowsLineEndings = fileExists(lockFilePath) &&
detectWindowsLineEndings(readTextFile(lockFilePath));
final serialized = serialize(p.dirname(lockFilePath), cache);
writeTextFile(
lockFilePath,
windowsLineEndings ? serialized.replaceAll('\n', '\r\n') : serialized,
);
}
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 _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 _directOverridden;
}
return _transitive;
}
/// `true` if [other] has the same packages as `this` in the same versions
/// from the same sources.
bool samePackageIds(LockFile other) {
if (packages.length != other.packages.length) {
return false;
}
for (final id in packages.values) {
final otherId = other.packages[id.name];
if (id != otherId) return false;
}
return true;
}
}
/// Returns `true` if the [text] looks like it uses windows line endings.
///
/// The heuristic used is to count all `\n` in the text and if stricly more than
/// half of them are preceded by `\r` we report `true`.
@visibleForTesting
bool detectWindowsLineEndings(String text) {
var index = -1;
var unixNewlines = 0;
var windowsNewlines = 0;
while ((index = text.indexOf('\n', index + 1)) != -1) {
if (index != 0 && text[index - 1] == '\r') {
windowsNewlines++;
} else {
unixNewlines++;
}
}
return windowsNewlines > unixNewlines;
}