blob: dc21f8c4e03f0fc496a4d8a2a719c1b79dd0108c [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:io';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'exceptions.dart';
import 'git.dart' as git;
import 'ignore.dart';
import 'io.dart';
import 'log.dart' as log;
import 'package_name.dart';
import 'pubspec.dart';
import 'source/root.dart';
import 'system_cache.dart';
import 'utils.dart';
/// A Package is a [Pubspec] and a directory where it belongs that can be used
/// for version solving or as a node in a package graph.
class Package {
/// Compares [a] and [b] orders them by name then version number.
///
/// This is normally used as a [Comparator] to pass to sort. This does not
/// take a package's description or root directory into account, so multiple
/// distinct packages may order the same.
static int orderByNameAndVersion(Package a, Package b) {
var name = a.name.compareTo(b.name);
if (name != 0) return name;
return a.version.compareTo(b.version);
}
/// The path to the directory containing the package.
final String dir;
/// A version of [dir] adapted for presenting in the terminal.
///
/// If [dir] is just a parent directory like ../.. it gets replaced with
/// the absolute dir.
late String presentationDir =
p.isWithin(dir, '.') ? p.normalize(p.absolute(dir)) : dir;
/// The name of the package.
String get name => pubspec.name;
/// The package's version.
Version get version => pubspec.version;
/// The parsed pubspec associated with this package.
final Pubspec pubspec;
/// The path to the entrypoint package's pubspec.
String get pubspecPath => p.normalize(p.join(dir, 'pubspec.yaml'));
/// The path to the entrypoint package's pubspec overrides file.
String get pubspecOverridesPath =>
p.normalize(p.join(dir, 'pubspec_overrides.yaml'));
/// The (non-transitive) workspace packages.
final List<Package> workspaceChildren;
/// The transitive closure of [workspaceChildren] rooted at this package.
///
/// Includes this package.
Iterable<Package> get transitiveWorkspace sync* {
final stack = [this];
while (stack.isNotEmpty) {
final current = stack.removeLast();
yield current;
// Because we pick from the end of the stack, elements are added in
// reverse, such that they will be visited in the order they appear in the
// list.
stack.addAll(current.workspaceChildren.reversed);
}
}
/// The immediate dependencies this package specifies in its pubspec.
Map<String, PackageRange> get dependencies => pubspec.dependencies;
/// The immediate dev dependencies this package specifies in its pubspec.
Map<String, PackageRange> get devDependencies => pubspec.devDependencies;
/// The dependency overrides this package specifies in its pubspec or pubspec
/// overrides.
Map<String, PackageRange> get dependencyOverrides =>
pubspec.dependencyOverrides;
/// All immediate dependencies this package specifies.
///
/// This includes regular, dev dependencies, and overrides.
Map<String, PackageRange> get immediateDependencies {
// Make sure to add overrides last so they replace normal dependencies.
return {}
..addAll(dependencies)
..addAll(devDependencies)
..addAll(dependencyOverrides);
}
/// Returns a list of paths to all Dart executables in this package's bin
/// directory.
List<String> get executablePaths {
final binDir = p.join(dir, 'bin');
if (!dirExists(binDir)) return <String>[];
return ordered(listDir(p.join(dir, 'bin'), includeDirs: false))
.where((executable) => p.extension(executable) == '.dart')
.map((executable) => p.relative(executable, from: dir))
.toList();
}
List<String> get executableNames =>
executablePaths.map(p.basenameWithoutExtension).toList();
/// Returns whether or not this package is in a Git repo.
late final bool inGitRepo = computeInGitRepoCache();
bool computeInGitRepoCache() {
if (!git.isInstalled) {
return false;
} else {
// If the entire package directory is ignored, don't consider it part of a
// git repo. `git check-ignore` will return a status code of 0 for
// ignored, 1 for not ignored, and 128 for not a Git repo.
var result = runProcessSync(
git.command!,
['check-ignore', '--quiet', '.'],
workingDir: dir,
);
return result.exitCode == 1;
}
}
/// Loads the package whose root directory is [dir].
///
/// Will also load the workspace sub-packages of this package (recursively).
///
/// [name] is the expected name of that package (e.g. the name given in the
/// dependency), or `null` if the package being loaded is the entrypoint
/// package.
///
/// `pubspec_overrides.yaml` is only loaded if [withPubspecOverrides] is
/// `true`.
///
/// [loadPubspec] if given will be used to obtain a pubspec from a path. Also
/// for the workspace children.
///
/// This mechanism can be used to avoid loading pubspecs twice. It can also be
/// used to override a pubspec in memory for trying out an alternative
/// resolution.
factory Package.load(
String dir,
SourceRegistry sources, {
bool withPubspecOverrides = false,
String? expectedName,
Pubspec Function(
String path, {
String? expectedName,
required bool withPubspecOverrides,
})? loadPubspec,
}) {
loadPubspec ??=
(path, {expectedName, required withPubspecOverrides}) => Pubspec.load(
path,
sources,
containingDescription: RootDescription(path),
);
final pubspec = loadPubspec(
dir,
withPubspecOverrides: withPubspecOverrides,
expectedName: expectedName,
);
final workspacePackages = pubspec.workspace.map(
(workspacePath) {
try {
return Package.load(
p.join(dir, workspacePath),
sources,
loadPubspec: loadPubspec,
withPubspecOverrides: withPubspecOverrides,
);
} on FileException catch (e) {
throw FileException(
'${e.message}\n'
'That was included in the workspace of ${p.join(dir, 'pubspec.yaml')}.',
e.path,
);
}
},
).toList();
for (final package in workspacePackages) {
if (package.pubspec.resolution != Resolution.workspace) {
fail('''
${package.pubspecPath} is included in the workspace from ${p.join(dir, 'pubspec.yaml')}, but does not have `resolution: workspace`.
See $workspacesDocUrl for more information.
''');
}
}
return Package(pubspec, dir, workspacePackages);
}
/// Creates a package with [pubspec] associated with [dir].
///
/// For temporary resolution attempts [pubspec] does not have to correspond
/// to the one at disk.
Package(this.pubspec, this.dir, this.workspaceChildren);
/// Given a relative path within this package, returns its absolute path.
///
/// This is similar to `p.join(dir, part1, ...)`, except that subclasses may
/// override it to report that certain paths exist elsewhere than within
/// [dir].
String path(
String? part1, [
String? part2,
String? part3,
String? part4,
String? part5,
String? part6,
String? part7,
]) {
return p.join(dir, part1, part2, part3, part4, part5, part6, part7);
}
/// Given an absolute path within this package (such as that returned by
/// [path] or [listFiles]), returns it relative to the package root.
String relative(String path) {
return p.relative(path, from: dir);
}
static final _basicIgnoreRules = [
'.*', // Don't include dot-files.
'!.htaccess', // Include .htaccess anyways.
'pubspec.lock',
'!pubspec.lock/', // We allow a directory called pubspec lock.
'/pubspec_overrides.yaml',
];
/// Returns a list of files that are considered to be part of this package.
///
/// If [beneath] is passed, this will only return files beneath that path,
/// which is expected to be relative to the package's root directory. If
/// [recursive] is true, this will return all files beneath that path;
/// otherwise, it will only return files one level beneath it.
///
/// This will take .pubignore and .gitignore files into account.
///
/// If [dir] is inside a git repository, all ignore files from the repo root
/// are considered.
///
/// For each directory a .pubignore takes precedence over a .gitignore.
///
/// Note that the returned paths will be always be below [dir], and will
/// always start with [dir] (thus always be relative to the current working
/// directory) or absolute id [dir] is absolute.
///
/// To convert them to paths relative to the package root, use [p.relative].
List<String> listFiles({String? beneath, bool recursive = true}) {
var packageDir = dir;
var root = git.repoRoot(packageDir) ?? packageDir;
beneath = p
.toUri(
p.normalize(
p.relative(p.join(packageDir, beneath ?? '.'), from: root),
),
)
.path;
if (beneath == './') beneath = '.';
String resolve(String path) {
if (Platform.isWindows) {
return p.joinAll([root, ...p.posix.split(path)]);
}
return p.join(root, path);
}
return Ignore.listFiles(
beneath: beneath,
listDir: (dir) {
var contents = Directory(resolve(dir)).listSync();
if (!recursive) {
contents = contents.where((entity) => entity is! Directory).toList();
}
return contents.map((entity) {
if (linkExists(entity.path)) {
final target = Link(entity.path).targetSync();
if (dirExists(entity.path)) {
throw DataException(
'''Pub does not support publishing packages with directory symlinks: `${entity.path}`.''',
);
}
if (!fileExists(entity.path)) {
throw DataException(
'''Pub does not support publishing packages with non-resolving symlink: `${entity.path}` => `$target`.''',
);
}
}
final relative = p.relative(entity.path, from: root);
if (Platform.isWindows) {
return p.posix.joinAll(p.split(relative));
}
return relative;
});
},
ignoreForDir: (dir) {
final pubIgnore = resolve('$dir/.pubignore');
final gitIgnore = resolve('$dir/.gitignore');
final ignoreFile = fileExists(pubIgnore)
? pubIgnore
: (fileExists(gitIgnore) ? gitIgnore : null);
final rules = [
if (dir == beneath) ..._basicIgnoreRules,
if (ignoreFile != null) readTextFile(ignoreFile),
];
return rules.isEmpty
? null
: Ignore(
rules,
onInvalidPattern: (pattern, exception) {
log.warning(
'$ignoreFile had invalid pattern $pattern. ${exception.message}',
);
},
// Ignore case on macOS and Windows, because `git clone` and
// `git init` will set `core.ignoreCase = true` in the local
// local `.git/config` file for the repository.
//
// So on Windows and macOS most users will have case-insensitive
// behavior with `.gitignore`, hence, it seems reasonable to do
// the same when we interpret `.gitignore` and `.pubignore`.
//
// There are cases where a user may have case-sensitive behavior
// with `.gitignore` on Windows and macOS:
//
// (A) The user has manually overwritten the repository
// configuration setting `core.ignoreCase = false`.
//
// (B) The git-clone or git-init command that create the
// repository did not deem `core.ignoreCase = true` to be
// appropriate. Documentation for [git-config]][1] implies
// this might depend on whether or not the filesystem is
// case sensitive:
// > If true, this option enables various workarounds to
// > enable Git to work better on filesystems that are not
// > case sensitive, like FAT.
// > ...
// > The default is false, except git-clone[1] or
// > git-init[1] will probe and set core.ignoreCase true
// > if appropriate when the repository is created.
//
// In either case, it seems likely that users on Windows and
// macOS will prefer case-insensitive matching. We specifically
// know that some tooling will generate `.PDB` files instead of
// `.pdb`, see: [#3003][2]
//
// [1]: https://git-scm.com/docs/git-config/2.14.6#Documentation/git-config.txt-coreignoreCase
// [2]: https://github.com/dart-lang/pub/issues/3003
ignoreCase: Platform.isMacOS || Platform.isWindows,
);
},
isDir: (dir) => dirExists(resolve(dir)),
).map(resolve).toList();
}
}
/// Reports an error if the graph of the workspace rooted at [root] is not a
/// tree. Or if a package name occurs twice.
void validateWorkspace(Package root) {
if (root.workspaceChildren.isEmpty) return;
final includedFrom = <String, String>{};
final stack = [root];
while (stack.isNotEmpty) {
final current = stack.removeLast();
for (final child in current.workspaceChildren) {
final previous = includedFrom[p.canonicalize(child.dir)];
if (previous != null) {
fail('''
Packages can only be included in the workspace once.
`${p.join(child.dir, 'pubspec.yaml')}` is included in the workspace, both from:
* `${p.join(current.dir, 'pubspec.yaml')}` and
* ${p.join(previous, 'pubspec.yaml')}.''');
}
includedFrom[p.canonicalize(child.dir)] = current.dir;
}
stack.addAll(current.workspaceChildren);
}
// Check that the workspace doesn't contain two packages with the same name!
final namesSeen = <String, Package>{};
for (final package in root.transitiveWorkspace) {
final collision = namesSeen[package.name];
if (collision != null) {
fail('''
Workspace members must have unique names.
`${collision.pubspecPath}` and `${package.pubspecPath}` are both called "${package.name}".
''');
}
namesSeen[package.name] = package;
}
}