| // 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:glob/glob.dart'; | |
| import 'package:pub_semver/pub_semver.dart'; | |
| import 'exceptions.dart'; | |
| import 'git.dart' as git; | |
| import 'ignore.dart'; | |
| import 'io.dart'; | |
| import 'language_version.dart'; | |
| import 'log.dart' as log; | |
| import 'package_name.dart'; | |
| import 'path.dart'; | |
| import 'platform_info.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.expand((workspacePath) { | |
| final packages = <Package>[]; | |
| var globHint = ''; | |
| if (pubspec.languageVersion.supportsWorkspaceGlobs && | |
| _hasGlobWildcards(workspacePath)) { | |
| final Glob glob; | |
| try { | |
| glob = Glob(workspacePath); | |
| } on FormatException catch (e) { | |
| fail('Failed to parse glob `$workspacePath`. $e'); | |
| } | |
| final globResults = | |
| glob | |
| .listFileSystemSync( | |
| currentFileSystem, | |
| root: p.absolute(dir), | |
| ) | |
| .toList(); | |
| for (final globResult in globResults) { | |
| final pubspecPath = p.join(globResult.path, 'pubspec.yaml'); | |
| if (!fileExists(pubspecPath)) continue; | |
| packages.add( | |
| Package.load( | |
| globResult.path, | |
| loadPubspec: loadPubspec, | |
| withPubspecOverrides: withPubspecOverrides, | |
| ), | |
| ); | |
| } | |
| } else { | |
| final pubspecPath = p.join( | |
| dir, | |
| _useBackSlashesOnWindows(workspacePath), | |
| 'pubspec.yaml', | |
| ); | |
| if (!fileExists(pubspecPath)) { | |
| if (!pubspec.languageVersion.supportsWorkspaceGlobs && | |
| _hasGlobWildcards(workspacePath)) { | |
| globHint = ''' | |
| \n\nGlob syntax is only supported from language version ${LanguageVersion.firstVersionWithWorkspaceGlobs}. | |
| Consider changing the language version of ${p.join(dir, 'pubspec.yaml')} to ${LanguageVersion.firstVersionWithWorkspaceGlobs}. | |
| '''; | |
| } | |
| } else { | |
| packages.add( | |
| Package.load( | |
| p.join(dir, _useBackSlashesOnWindows(workspacePath)), | |
| loadPubspec: loadPubspec, | |
| withPubspecOverrides: withPubspecOverrides, | |
| ), | |
| ); | |
| } | |
| } | |
| if (packages.isEmpty) { | |
| fail(''' | |
| No workspace packages matching `$workspacePath`. | |
| That was included in the workspace of `${p.join(dir, 'pubspec.yaml')}`.$globHint | |
| '''); | |
| } | |
| return packages; | |
| }).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.posix.joinAll( | |
| p.split( | |
| p.normalize(p.relative(p.join(packageDir, beneath ?? '.'), from: root)), | |
| ), | |
| ); | |
| 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. | |
| '''); | |
| } | |
| } | |
| } | |
| } | |
| /// Returns whether [s] contains any glob wildcard syntax characters. | |
| /// | |
| /// We intentionally do not account for backslash-escaped wildcards (like `\*`). | |
| /// Paths without any of these characters are guaranteed to be plain relative | |
| /// paths that can be checked directly on the filesystem without unescaping or | |
| /// glob parsing. Paths containing these characters (even if escaped) are | |
| /// delegated to `package:glob` to handle unescaping properly and to avoid | |
| /// conflicting with Windows `\` path separators. | |
| bool _hasGlobWildcards(String s) => | |
| s.contains('*') || s.contains('?') || s.contains('[') || s.contains('{'); | |
| String _useBackSlashesOnWindows(String path) { | |
| if (platform.isWindows) { | |
| return p.joinAll(p.split(path)); | |
| } | |
| return path; | |
| } |