// 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. library pub.pubspec; import 'package:path/path.dart' as path; import 'package:pub_semver/pub_semver.dart'; import 'package:source_span/source_span.dart'; import 'package:yaml/yaml.dart'; import 'barback/transformer_config.dart'; import 'exceptions.dart'; import 'io.dart'; import 'package.dart'; import 'source_registry.dart'; import 'utils.dart'; /// The parsed contents of a pubspec file. /// /// The fields of a pubspec are, for the most part, validated when they're first /// accessed. This allows a partially-invalid pubspec to be used if only the /// valid portions are relevant. To get a list of all errors in the pubspec, use /// [allErrors]. class Pubspec { // If a new lazily-initialized field is added to this class and the // initialization can throw a [PubspecException], that error should also be // exposed through [allErrors]. /// 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; /// The location from which the pubspec was loaded. /// /// This can be null if the pubspec was created in-memory or if its location /// is unknown. Uri get _location => fields.span.sourceUrl; /// All pubspec fields. /// /// This includes the fields from which other properties are derived. final YamlMap fields; /// The package's name. String get name { if (_name != null) return _name; var name = fields['name']; if (name == null) { throw new PubspecException( 'Missing the required "name" field.', fields.span); } else if (name is! String) { throw new PubspecException( '"name" field must be a string.', fields.nodes['name'].span); } _name = name; return _name; } String _name; /// The package's version. Version get version { if (_version != null) return _version; var version = fields['version']; if (version == null) { _version = Version.none; return _version; } var span = fields.nodes['version'].span; if (version is num) { var fixed = '$version.0'; if (version is int) { fixed = '$fixed.0'; } _error('"version" field must have three numeric components: major, ' 'minor, and patch. Instead of "$version", consider "$fixed".', span); } if (version is! String) { _error('"version" field must be a string.', span); } _version = _wrapFormatException('version number', span, () => new Version.parse(version)); return _version; } Version _version; /// The additional packages this package depends on. List<PackageDep> get dependencies { if (_dependencies != null) return _dependencies; _dependencies = _parseDependencies('dependencies'); _checkDependencyOverlap(_dependencies, _devDependencies); return _dependencies; } List<PackageDep> _dependencies; /// The packages this package depends on when it is the root package. List<PackageDep> get devDependencies { if (_devDependencies != null) return _devDependencies; _devDependencies = _parseDependencies('dev_dependencies'); _checkDependencyOverlap(_dependencies, _devDependencies); return _devDependencies; } List<PackageDep> _devDependencies; /// The dependency constraints that this package overrides when it is the /// root package. /// /// Dependencies here will replace any dependency on a package with the same /// name anywhere in the dependency graph. List<PackageDep> get dependencyOverrides { if (_dependencyOverrides != null) return _dependencyOverrides; _dependencyOverrides = _parseDependencies('dependency_overrides'); return _dependencyOverrides; } List<PackageDep> _dependencyOverrides; /// The configurations of the transformers to use for this package. List<Set<TransformerConfig>> get transformers { if (_transformers != null) return _transformers; var transformers = fields['transformers']; if (transformers == null) { _transformers = []; return _transformers; } if (transformers is! List) { _error('"transformers" field must be a list.', fields.nodes['transformers'].span); } _transformers = transformers.nodes.map((phase) { var phaseNodes = phase is YamlList ? phase.nodes : [phase]; return phaseNodes.map((transformerNode) { var transformer = transformerNode.value; if (transformer is! String && transformer is! Map) { _error('A transformer must be a string or map.', transformerNode.span); } var libraryNode; var configurationNode; if (transformer is String) { libraryNode = transformerNode; } else { if (transformer.length != 1) { _error('A transformer map must have a single key: the transformer ' 'identifier.', transformerNode.span); } else if (transformer.keys.single is! String) { _error('A transformer identifier must be a string.', transformer.nodes.keys.single.span); } libraryNode = transformer.nodes.keys.single; configurationNode = transformer.nodes.values.single; if (configurationNode is! YamlMap) { _error("A transformer's configuration must be a map.", configurationNode.span); } } var config = _wrapSpanFormatException('transformer config', () { return new TransformerConfig.parse( libraryNode.value, libraryNode.span, configurationNode); }); var package = config.id.package; if (package != name && !config.id.isBuiltInTransformer && !dependencies.any((ref) => ref.name == package) && !devDependencies.any((ref) => ref.name == package) && !dependencyOverrides.any((ref) => ref.name == package)) { _error('"$package" is not a dependency.', libraryNode.span); } return config; }).toSet(); }).toList(); return _transformers; } List<Set<TransformerConfig>> _transformers; /// The environment-related metadata. PubspecEnvironment get environment { if (_environment != null) return _environment; var yaml = fields['environment']; if (yaml == null) { _environment = new PubspecEnvironment(VersionConstraint.any); return _environment; } if (yaml is! Map) { _error('"environment" field must be a map.', fields.nodes['environment'].span); } _environment = new PubspecEnvironment( _parseVersionConstraint(yaml.nodes['sdk'])); return _environment; } PubspecEnvironment _environment; /// The URL of the server that the package should default to being published /// to, "none" if the package should not be published, or `null` if it should /// be published to the default server. /// /// If this does return a URL string, it will be a valid parseable URL. String get publishTo { if (_parsedPublishTo) return _publishTo; var publishTo = fields['publish_to']; if (publishTo != null) { var span = fields.nodes['publish_to'].span; if (publishTo is! String) { _error('"publish_to" field must be a string.', span); } // It must be "none" or a valid URL. if (publishTo != "none") { _wrapFormatException('"publish_to" field', span, () => Uri.parse(publishTo)); } } _parsedPublishTo = true; _publishTo = publishTo; return _publishTo; } bool _parsedPublishTo = false; String _publishTo; /// The executables that should be placed on the user's PATH when this /// package is globally activated. /// /// It is a map of strings to string. Each key is the name of the command /// that will be placed on the user's PATH. The value is the name of the /// .dart script (without extension) in the package's `bin` directory that /// should be run for that command. Both key and value must be "simple" /// strings: alphanumerics, underscores and hypens only. If a value is /// omitted, it is inferred to use the same name as the key. Map<String, String> get executables { if (_executables != null) return _executables; _executables = {}; var yaml = fields['executables']; if (yaml == null) return _executables; if (yaml is! Map) { _error('"executables" field must be a map.', fields.nodes['executables'].span); } yaml.nodes.forEach((key, value) { if (key.value is! String) { _error('"executables" keys must be strings.', key.span); } final keyPattern = new RegExp(r"^[a-zA-Z0-9_-]+$"); if (!keyPattern.hasMatch(key.value)) { _error('"executables" keys may only contain letters, ' 'numbers, hyphens and underscores.', key.span); } if (value.value == null) { value = key; } else if (value.value is! String) { _error('"executables" values must be strings or null.', value.span); } final valuePattern = new RegExp(r"[/\\]"); if (valuePattern.hasMatch(value.value)) { _error('"executables" values may not contain path separators.', value.span); } _executables[key.value] = value.value; }); return _executables; } Map<String, String> _executables; /// Whether the package is private and cannot be published. /// /// This is specified in the pubspec by setting "publish_to" to "none". bool get isPrivate => publishTo == "none"; /// Whether or not the pubspec has no contents. bool get isEmpty => name == null && version == Version.none && dependencies.isEmpty; /// 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 [PubspecError]. factory Pubspec.load(String packageDir, SourceRegistry sources, {String expectedName}) { var pubspecPath = path.join(packageDir, 'pubspec.yaml'); var pubspecUri = path.toUri(pubspecPath); if (!fileExists(pubspecPath)) { throw new FileException( // Make the package dir absolute because for the entrypoint it'll just // be ".", which may be confusing. 'Could not find a file named "pubspec.yaml" in ' '"${path.normalize(path.absolute(packageDir))}".', pubspecPath); } return new Pubspec.parse(readTextFile(pubspecPath), sources, expectedName: expectedName, location: pubspecUri); } Pubspec(this._name, {Version version, Iterable<PackageDep> dependencies, Iterable<PackageDep> devDependencies, Iterable<PackageDep> dependencyOverrides, VersionConstraint sdkConstraint, Iterable<Iterable<TransformerConfig>> transformers, Map fields, SourceRegistry sources}) : _version = version, _dependencies = dependencies == null ? null : dependencies.toList(), _devDependencies = devDependencies == null ? null : devDependencies.toList(), _dependencyOverrides = dependencyOverrides == null ? null : dependencyOverrides.toList(), _environment = new PubspecEnvironment(sdkConstraint), _transformers = transformers == null ? [] : transformers.map((phase) => phase.toSet()).toList(), fields = fields == null ? new YamlMap() : new YamlMap.wrap(fields), _sources = sources; Pubspec.empty() : _sources = null, _name = null, _version = Version.none, _dependencies = <PackageDep>[], _devDependencies = <PackageDep>[], _environment = new PubspecEnvironment(), _transformers = <Set<TransformerConfig>>[], fields = new YamlMap(); /// Returns a Pubspec object for an already-parsed map representing its /// contents. /// /// If [expectedName] is passed and the pubspec doesn't have a matching name /// field, this will throw a [PubspecError]. /// /// [location] is the location from which this pubspec was loaded. Pubspec.fromMap(Map fields, this._sources, {String expectedName, Uri location}) : fields = fields is YamlMap ? fields : new YamlMap.wrap(fields, sourceUrl: location) { // If [expectedName] is passed, ensure that the actual 'name' field exists // and matches the expectation. if (expectedName == null) return; if (name == expectedName) return; throw new PubspecException('"name" field doesn\'t match expected name ' '"$expectedName".', this.fields.nodes["name"].span); } /// Parses the pubspec stored at [filePath] 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}) { var pubspecNode = loadYamlNode(contents, sourceUrl: location); if (pubspecNode is YamlScalar && pubspecNode.value == null) { pubspecNode = new YamlMap(sourceUrl: location); } else if (pubspecNode is! YamlMap) { throw new PubspecException( 'The pubspec must be a YAML mapping.', pubspecNode.span); } return new Pubspec.fromMap(pubspecNode, sources, expectedName: expectedName, location: location); } /// 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>[]; _getError(fn()) { try { fn(); } on PubspecException catch (e) { errors.add(e); } } _getError(() => this.name); _getError(() => this.version); _getError(() => this.dependencies); _getError(() => this.devDependencies); _getError(() => this.transformers); _getError(() => this.environment); _getError(() => this.publishTo); return errors; } /// Parses the dependency field named [field], and returns the corresponding /// list of dependencies. List<PackageDep> _parseDependencies(String field) { var dependencies = <PackageDep>[]; var yaml = fields[field]; // Allow an empty dependencies key. if (yaml == null) return dependencies; if (yaml is! Map) { _error('"$field" field must be a map.', fields.nodes[field].span); } var nonStringNode = yaml.nodes.keys.firstWhere((e) => e.value is! String, orElse: () => null); if (nonStringNode != null) { _error('A dependency name must be a string.', nonStringNode.span); } yaml.nodes.forEach((nameNode, specNode) { var name = nameNode.value; var spec = specNode.value; if (fields['name'] != null && name == this.name) { _error('A package may not list itself as a dependency.', nameNode.span); } var descriptionNode; var sourceName; var versionConstraint = new VersionRange(); 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) { // Don't write to the immutable YAML map. spec = new Map.from(spec); if (spec.containsKey('version')) { spec.remove('version'); versionConstraint = _parseVersionConstraint( specNode.nodes['version']); } var sourceNames = spec.keys.toList(); if (sourceNames.length > 1) { _error('A dependency may only have one source.', specNode.span); } sourceName = sourceNames.single; if (sourceName is! String) { _error('A source name must be a string.', specNode.nodes.keys.single.span); } descriptionNode = specNode.nodes[sourceName]; } else { _error('A dependency specification must be a string or a mapping.', specNode.span); } // Let the source validate the description. var description = _wrapFormatException('description', descriptionNode.span, () { var pubspecPath; if (_location != null && _isFileUri(_location)) { pubspecPath = path.fromUri(_location); } return _sources[sourceName].parseDescription( pubspecPath, descriptionNode.value, fromLockFile: false); }); dependencies.add(new PackageDep( name, sourceName, versionConstraint, description)); }); return dependencies; } /// Parses [node] to a [VersionConstraint]. VersionConstraint _parseVersionConstraint(YamlNode node) { if (node.value == null) return VersionConstraint.any; if (node.value is! String) { _error('A version constraint must be a string.', node.span); } return _wrapFormatException('version constraint', node.span, () => new VersionConstraint.parse(node.value)); } /// Makes sure the same package doesn't appear as both a regular and dev /// dependency. void _checkDependencyOverlap(List<PackageDep> dependencies, List<PackageDep> devDependencies) { if (dependencies == null) return; if (devDependencies == null) return; var dependencyNames = dependencies.map((dep) => dep.name).toSet(); var collisions = dependencyNames.intersection( devDependencies.map((dep) => dep.name).toSet()); if (collisions.isEmpty) return; var span = fields["dependencies"].nodes.keys .firstWhere((key) => collisions.contains(key.value)).span; // TODO(nweiz): associate source range info with PackageDeps and use it // here. _error('${pluralize('Package', collisions.length)} ' '${toSentence(collisions.map((package) => '"$package"'))} cannot ' 'appear in both "dependencies" and "dev_dependencies".', 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. _wrapFormatException(String description, SourceSpan span, fn()) { try { return fn(); } on FormatException catch (e) { _error('Invalid $description: ${e.message}', span); } } _wrapSpanFormatException(String description, fn()) { try { return fn(); } on SourceSpanFormatException catch (e) { _error('Invalid $description: ${e.message}', e.span); } } /// Throws a [PubspecException] with the given message. void _error(String message, SourceSpan span) { throw new PubspecException(message, span); } } /// The environment-related metadata in the pubspec. /// /// Corresponds to the data under the "environment:" key in the pubspec. class PubspecEnvironment { /// The version constraint specifying which SDK versions this package works /// with. final VersionConstraint sdkVersion; PubspecEnvironment([VersionConstraint sdk]) : sdkVersion = sdk != null ? sdk : VersionConstraint.any; } /// 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); } /// Returns whether [uri] is a file URI. /// /// 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 == '';