| // 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 '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) { |
| final 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 final 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); |
| } |
| } |
| |
| /// A collection of all overrides in the workspace. |
| /// |
| /// Should only be called on the workspace root. |
| /// |
| /// We only allow each package to be overridden once, so it is ok to collapse |
| /// the overrides into a single map. |
| late final Map<String, PackageRange> allOverridesInWorkspace = { |
| for (final package in transitiveWorkspace) |
| ...package.pubspec.dependencyOverrides, |
| }; |
| |
| /// 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; |
| |
| /// All immediate dependencies this package specifies. |
| /// |
| /// This includes regular, dev dependencies, and overrides from this package. |
| Map<String, PackageRange> get immediateDependencies { |
| // Make sure to add overrides last so they replace normal dependencies. |
| return { |
| ...dependencies, |
| ...devDependencies, |
| ...pubspec.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 [ |
| for (var executable in listDir(binDir, includeDirs: false)) |
| if (p.extension(executable) == '.dart') |
| p.relative(executable, from: dir), |
| ]..sort(); |
| } |
| |
| 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. |
| final 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, { |
| bool withPubspecOverrides = false, |
| String? expectedName, |
| required Pubspec Function( |
| String path, { |
| String? expectedName, |
| required bool withPubspecOverrides, |
| }) |
| loadPubspec, |
| }) { |
| final pubspec = loadPubspec( |
| dir, |
| withPubspecOverrides: withPubspecOverrides, |
| expectedName: expectedName, |
| ); |
| |
| final workspacePackages = |
| pubspec.workspace.map((workspacePath) { |
| try { |
| return Package.load( |
| p.join(dir, workspacePath), |
| loadPubspec: loadPubspec, |
| withPubspecOverrides: withPubspecOverrides, |
| ); |
| } on FileException catch (e) { |
| final pubspecPath = p.join(dir, 'pubspec.yaml'); |
| throw FileException( |
| '${e.message}\n' |
| 'That was included in the workspace of $pubspecPath.', |
| 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, |
| bool includeDirs = false, |
| }) { |
| final packageDir = dir; |
| final 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); |
| } |
| |
| /// Throws if [path] is a link that cannot resolve. |
| /// |
| /// Circular links will fail to resolve at some depth defined by the os. |
| void verifyLink(String path) { |
| final link = Link(path); |
| if (link.existsSync()) { |
| try { |
| link.resolveSymbolicLinksSync(); |
| } on FileSystemException catch (e) { |
| if (!link.existsSync()) { |
| return; |
| } |
| throw DataException('Could not resolve symbolic link $path. $e'); |
| } |
| } |
| } |
| |
| /// We check each directory that it doesn't symlink-resolve to the |
| /// symlink-resolution of any parent directory of itself. This avoids |
| /// cycles. |
| /// |
| /// Cache the symlink resolutions here. |
| final symlinkResolvedDirs = <String, String>{}; |
| String resolveDirSymlinks(String path) { |
| return symlinkResolvedDirs[path] ??= |
| Directory(path).resolveSymbolicLinksSync(); |
| } |
| |
| final result = |
| Ignore.listFiles( |
| beneath: beneath, |
| listDir: (dir) { |
| final resolvedDir = p.normalize(resolve(dir)); |
| verifyLink(resolvedDir); |
| |
| { |
| final canonicalized = p.canonicalize(resolvedDir); |
| final symlinkResolvedDir = resolveDirSymlinks(canonicalized); |
| for (final parent in parentDirs(p.dirname(canonicalized))) { |
| final symlinkResolvedParent = resolveDirSymlinks(parent); |
| if (p.equals(symlinkResolvedDir, symlinkResolvedParent)) { |
| dataError(''' |
| Pub does not support symlink cycles. |
| |
| $symlinkResolvedDir => ${p.canonicalize(symlinkResolvedParent)} |
| '''); |
| } |
| } |
| } |
| var contents = Directory(resolvedDir).listSync(followLinks: false); |
| |
| if (!recursive) { |
| contents = |
| contents.where((entity) => entity is! Directory).toList(); |
| } |
| return contents.map((entity) { |
| 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)), |
| includeDirs: includeDirs, |
| ).map(resolve).toList(); |
| for (final f in result) { |
| verifyLink(f); |
| } |
| return result; |
| } |
| |
| /// Applies [transform] to each package in the workspace and returns a derived |
| /// package. |
| Package transformWorkspace(Pubspec Function(Package) transform) { |
| final workspace = { |
| for (final package in transitiveWorkspace) package.dir: package, |
| }; |
| return Package.load( |
| dir, |
| withPubspecOverrides: true, |
| loadPubspec: |
| (path, {expectedName, required withPubspecOverrides}) => |
| transform(workspace[path]!), |
| ); |
| } |
| } |
| |
| /// Reports an error if one or more of: |
| /// |
| /// * The graph of the workspace rooted at [root] is not a tree. |
| /// * If a package name occurs twice. |
| /// * If two packages in the workspace override the same package name. |
| /// * A workspace package is overridden. |
| /// * A pubspec not included in the workspace exists in a directory |
| /// between the root and a workspace package. |
| void validateWorkspace(Package root) { |
| if (root.workspaceChildren.isEmpty) return; |
| |
| /// Maps the `p.canonicalize`d dir of each workspace-child to its parent. |
| 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) { |
| if (previous == current.dir) { |
| fail( |
| ''' |
| Packages can only be included in the workspace once. |
| |
| `${p.join(child.dir, 'pubspec.yaml')}` is included twice into the workspace of `${p.join(current.dir, 'pubspec.yaml')}`''', |
| ); |
| } |
| 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; |
| } |
| |
| // Check that the workspace doesn't contain two overrides of the same package. |
| // Also check that workspace packages are not overridden. |
| final overridesSeen = <String, Package>{}; |
| for (final package in root.transitiveWorkspace) { |
| for (final override in package.pubspec.dependencyOverrides.keys) { |
| final collision = overridesSeen[override]; |
| if (collision != null) { |
| fail(''' |
| The package `$override` is overridden in both: |
| package `${collision.name}` at `${collision.dir}` and '${package.name}' at `${package.dir}`. |
| |
| Consider removing one of the overrides. |
| '''); |
| } |
| overridesSeen[override] = package; |
| |
| if (namesSeen[override] case final Package overriddenWorkspacePackage) { |
| fail(''' |
| Cannot override workspace packages. |
| |
| Package `$override` at `${overriddenWorkspacePackage.presentationDir}` is overridden in `${package.pubspecPath}`. |
| '''); |
| } |
| } |
| } |
| |
| // Check for pubspec.yaml files between the root and any workspace package. |
| final visited = <String>{ |
| // By adding this to visited we will never go above the workspaceRoot.dir. |
| p.canonicalize(root.dir), |
| }; |
| for (final package in root.transitiveWorkspace |
| // We don't want to look at the roots parents. The first package is always |
| // the root, so skip that. |
| .skip(1)) { |
| // Run through all parent directories until we meet another workspace |
| // package. |
| for (final dir in parentDirs(package.dir).skip(1)) { |
| // Stop if we meet another package directory. |
| if (includedFrom.containsKey(p.canonicalize(dir))) { |
| break; |
| } |
| if (!visited.add(p.canonicalize(dir))) { |
| // We have been here before. |
| break; |
| } |
| final pubspecCandidate = p.join(dir, 'pubspec.yaml'); |
| if (fileExists(pubspecCandidate)) { |
| fail(''' |
| The file `$pubspecCandidate` is located in a directory between the workspace root at |
| `${root.dir}` and a workspace package at `${package.dir}`. But is not a member of the |
| workspace. |
| |
| This blocks the resolution of the package at `${package.dir}`. |
| |
| Consider removing it. |
| |
| See https://dart.dev/go/workspaces-stray-files for details. |
| '''); |
| } |
| } |
| } |
| } |