Merge glob package (#1974)
diff --git a/.github/ISSUE_TEMPLATE/glob.md b/.github/ISSUE_TEMPLATE/glob.md new file mode 100644 index 0000000..16b971e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/glob.md
@@ -0,0 +1,5 @@ +--- +name: "package:glob" +about: "Create a bug or file a feature request against package:glob." +labels: "package:glob" +--- \ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/unified_analytics_event.yml b/.github/ISSUE_TEMPLATE/unified_analytics_event.yml index 52c8d83..6e949cf 100644 --- a/.github/ISSUE_TEMPLATE/unified_analytics_event.yml +++ b/.github/ISSUE_TEMPLATE/unified_analytics_event.yml
@@ -1,6 +1,7 @@ name: "package:unified_analytics - request a new event" description: "Create a request for collecting a new event or new event data." -labels: "package:unified_analytics" +labels: + - "package:unified_analytics" body: - type: markdown attributes:
diff --git a/.github/ISSUE_TEMPLATE/unified_analytics_user_property.yml b/.github/ISSUE_TEMPLATE/unified_analytics_user_property.yml index 3fc960e..cbf17b9 100644 --- a/.github/ISSUE_TEMPLATE/unified_analytics_user_property.yml +++ b/.github/ISSUE_TEMPLATE/unified_analytics_user_property.yml
@@ -1,6 +1,7 @@ name: "package:unified_analytics - request a new user property" description: "Create a request for collecting a new user property." -labels: "package:unified_analytics" +labels: + - "package:unified_analytics" body: - type: markdown attributes:
diff --git a/.github/labeler.yml b/.github/labeler.yml index 0bb7feb..6bdbffd 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml
@@ -56,6 +56,10 @@ - changed-files: - any-glob-to-any-file: 'pkgs/file_testing/**' +'package:glob': + - changed-files: + - any-glob-to-any-file: 'pkgs/glob/**' + 'package:graphs': - changed-files: - any-glob-to-any-file: 'pkgs/graphs/**'
diff --git a/.github/workflows/glob.yaml b/.github/workflows/glob.yaml new file mode 100644 index 0000000..26e6355 --- /dev/null +++ b/.github/workflows/glob.yaml
@@ -0,0 +1,39 @@ +name: package:glob + +permissions: read-all + +on: + # Run CI on all PRs (against any branch) and on pushes to the main branch. + pull_request: + paths: + - '.github/workflows/glob.yaml' + - 'pkgs/glob/**' + push: + branches: [ main ] + paths: + - '.github/workflows/glob.yaml' + - 'pkgs/glob/**' + schedule: + - cron: '0 0 * * 0' # weekly + +defaults: + run: + working-directory: pkgs/glob + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [stable, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - run: dart pub get + - run: dart analyze --fatal-infos + - run: dart format --output=none --set-exit-if-changed . + if: ${{ matrix.sdk == 'stable' }} + - run: dart test
diff --git a/README.md b/README.md index ac242eb..057f245 100644 --- a/README.md +++ b/README.md
@@ -27,6 +27,7 @@ | [extension_discovery](pkgs/extension_discovery/) | A convention and utilities for package extension discovery. | [][extension_discovery_issues] | [](https://pub.dev/packages/extension_discovery) | | [file](pkgs/file/) | A pluggable, mockable file system abstraction for Dart. | [][file_issues] | [](https://pub.dev/packages/file) | | [file_testing](pkgs/file_testing/) | Testing utilities for package:file. | [][file_testing_issues] | [](https://pub.dev/packages/file_testing) | +| [glob](pkgs/glob/) | A library to perform Bash-style file and directory globbing. | [][glob_issues] | [](https://pub.dev/packages/glob) | | [graphs](pkgs/graphs/) | Graph algorithms that operate on graphs in any representation. | [][graphs_issues] | [](https://pub.dev/packages/graphs) | | [html](pkgs/html/) | APIs for parsing and manipulating HTML content outside the browser. | [][html_issues] | [](https://pub.dev/packages/html) | | [io](pkgs/io/) | Utilities for the Dart VM Runtime including support for ANSI colors, file copying, and standard exit code values. | [][io_issues] | [](https://pub.dev/packages/io) | @@ -67,6 +68,7 @@ [extension_discovery_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aextension_discovery [file_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Afile [file_testing_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Afile_testing +[glob_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aglob [graphs_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Agraphs [html_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Ahtml [io_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aio
diff --git a/pkgs/glob/.gitignore b/pkgs/glob/.gitignore new file mode 100644 index 0000000..ab3cb76 --- /dev/null +++ b/pkgs/glob/.gitignore
@@ -0,0 +1,16 @@ +# Don’t commit the following directories created by pub. +.buildlog +.dart_tool/ +.pub/ +build/ +packages +.packages + +# Or the files created by dart2js. +*.dart.js +*.js_ +*.js.deps +*.js.map + +# Include when developing application packages. +pubspec.lock
diff --git a/pkgs/glob/CHANGELOG.md b/pkgs/glob/CHANGELOG.md new file mode 100644 index 0000000..a7ca915 --- /dev/null +++ b/pkgs/glob/CHANGELOG.md
@@ -0,0 +1,115 @@ +## 2.1.3 + +- Require Dart 3.3. +- Move to `dart-lang/tools` monorepo. + +## 2.1.2 + +- Allow `file` version `7.x`. +- Require Dart 2.19. + +## 2.1.1 + +- Updated the dependency on `package:file` to require at least `6.1.3`. + +## 2.1.0 + +- Return empty results instead of throwing when trying to list a path that does + not exist. + +## 2.0.2 + +- Drop package:pedantic dependency, use package:lints instead. +- Update SDK lower bound to `2.15.0` + +## 2.0.1 + +- Update example in README for new import. + +## 2.0.0 + +- Stable null safety release. + +### Breaking Change + +The `list*` apis on `Glob` have been renamed to `listFileSystem*` and they now +require a `FileSystem` object from `package:file`. + +There is a new convenience import, `package:glob/list_local_fs.dart` which +provides the old methods as extensions, and automatically passes a +`LocalFileSystem`. + +## 1.2.1 + +- Add an empty list_local_fs.dart to ease upgrade from 1x to 2x + +## 1.2.0 + +- Support running on Node.js. + +## 1.1.7 + +- Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 1.1.6 + +- Improve support for Dart 2 runtime semantics. + +## 1.1.5 + +- Declare support for `async` 2.0.0. + +- Require Dart 1.23.0. + +## 1.1.4 + +- Throw an exception when listing globs whose initial paths don't exist in + case-insensitive mode. This matches the case-sensitive behavior. + +## 1.1.3 + +- Support `string_scanner` 1.0.0. + +## 1.1.2 + +- Fix all strong mode errors and warnings. + +## 1.1.1 + +- Fix a bug where listing an absolute glob with `caseInsensitive: false` failed. + +## 1.1.0 + +- Add a `caseSensitive` named parameter to `new Glob()` that controls whether + the glob is case-sensitive. This defaults to `false` on Windows and `true` + elsewhere. + + Matching case-insensitively on Windows is a behavioral change, but since it + more closely matches the semantics of Windows paths it's considered a bug fix + rather than a breaking change. + +## 1.0.5 + +- Narrow the dependency on `path`. Previously, this allowed versions that didn't + support all the functionality this package needs. + +- Upgrade to the new test runner. + +## 1.0.4 + +- Added overlooked `collection` dependency. + +## 1.0.3 + +- Fix a bug where `Glob.list()` and `Glob.listSync()` would incorrectly throw + exceptions when a directory didn't exist on the filesystem. + +## 1.0.2 + +- Fixed `Glob.list()` on Windows. + +## 1.0.1 + +- Fix several analyzer warnings. + +- Fix the tests on Windows.
diff --git a/pkgs/glob/LICENSE b/pkgs/glob/LICENSE new file mode 100644 index 0000000..000cd7b --- /dev/null +++ b/pkgs/glob/LICENSE
@@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/pkgs/glob/README.md b/pkgs/glob/README.md new file mode 100644 index 0000000..ef6a745 --- /dev/null +++ b/pkgs/glob/README.md
@@ -0,0 +1,132 @@ +[](https://github.com/dart-lang/glob/actions/workflows/test-package.yml) +[](https://pub.dev/packages/glob) +[](https://pub.dev/packages/glob/publisher) + +`glob` is a file and directory globbing library that supports both checking +whether a path matches a glob and listing all entities that match a glob. + +A "glob" is a pattern designed specifically to match files and directories. Most +shells support globs natively. + +## Usage + +To construct a glob, just use `Glob()`. As with `RegExp`s, it's a good idea to +keep around a glob if you'll be using it more than once so that it doesn't have +to be compiled over and over. You can check whether a path matches the glob +using `Glob.matches()`: + +```dart +import 'package:glob/glob.dart'; + +final dartFile = Glob("**.dart"); + +// Print all command-line arguments that are Dart files. +void main(List<String> arguments) { + for (var argument in arguments) { + if (dartFile.matches(argument)) print(argument); + } +} +``` + +You can also list all files that match a glob using `Glob.list()` or +`Glob.listSync()`: + +```dart +import 'package:glob/glob.dart'; +import 'package:glob/list_local_fs.dart'; + +final dartFile = Glob("**.dart"); + +// Recursively list all Dart files in the current directory. +void main(List<String> arguments) { + for (var entity in dartFile.listSync()) { + print(entity.path); + } +} +``` + +## Syntax + +The glob syntax hews closely to the widely-known Bash glob syntax, with a few +exceptions that are outlined below. + +In order to be as cross-platform and as close to the Bash syntax as possible, +all globs use POSIX path syntax, including using `/` as a directory separator +regardless of which platform they're on. This is true even for Windows roots; +for example, a glob matching all files in the C drive would be `C:/*`. + +Globs are case-sensitive by default on Posix systems and browsers, and +case-insensitive by default on Windows. + +### Match any characters in a filename: `*` + +The `*` character matches zero or more of any character other than `/`. This +means that it can be used to match all files in a given directory that match a +pattern without also matching files in a subdirectory. For example, `lib/*.dart` +will match `lib/glob.dart` but not `lib/src/utils.dart`. + +### Match any characters across directories: `**` + +`**` is like `*`, but matches `/` as well. It's useful for matching files or +listing directories recursively. For example, `lib/**.dart` will match both +`lib/glob.dart` and `lib/src/utils.dart`. + +If `**` appears at the beginning of a glob, it won't match absolute paths or +paths beginning with `../`. For example, `**.dart` won't match `/foo.dart`, +although `/**.dart` will. This is to ensure that listing a bunch of paths and +checking whether they match a glob produces the same results as listing that +glob. In the previous example, `/foo.dart` wouldn't be listed for `**.dart`, so +it shouldn't be matched by it either. + +This is an extension to Bash glob syntax that's widely supported by other glob +implementations. + +### Match any single character: `?` + +The `?` character matches a single character other than `/`. Unlike `*`, it +won't match any more or fewer than one character. For example, `test?.dart` will +match `test1.dart` but not `test10.dart` or `test.dart`. + +### Match a range of characters: `[...]` + +The `[...]` construction matches one of several characters. It can contain +individual characters, such as `[abc]`, in which case it will match any of those +characters; it can contain ranges, such as `[a-zA-Z]`, in which case it will +match any characters that fall within the range; or it can contain a mix of +both. It will only ever match a single character. For example, +`test[a-zA-Z_].dart` will match `testx.dart`, `testA.dart`, and `test_.dart`, +but not `test-.dart`. + +If it starts with `^` or `!`, the construction will instead match all characters +_not_ mentioned. For example, `test[^a-z].dart` will match `test1.dart` but not +`testa.dart`. + +This construction never matches `/`. + +### Match one of several possibilities: `{...,...}` + +The `{...,...}` construction matches one of several options, each of which is a +glob itself. For example, `lib/{*.dart,src/*}` matches `lib/glob.dart` and +`lib/src/data.txt`. It can contain any number of options greater than one, and +can even contain nested options. + +This is an extension to Bash glob syntax, although it is supported by other +layers of Bash and is often used in conjunction with globs. + +### Escaping a character: `\` + +The `\` character can be used in any context to escape a character that would +otherwise be semantically meaningful. For example, `\*.dart` matches `*.dart` +but not `test.dart`. + +### Syntax errors + +Because they're used as part of the shell, almost all strings are valid Bash +globs. This implementation is more picky, and performs some validation to ensure +that globs are meaningful. For instance, unclosed `{` and `[` are disallowed. + +### Reserved syntax: `(...)` + +Parentheses are reserved in case this package adds support for Bash extended +globbing in the future. For the time being, using them will throw an error +unless they're escaped.
diff --git a/pkgs/glob/analysis_options.yaml b/pkgs/glob/analysis_options.yaml new file mode 100644 index 0000000..813cc96 --- /dev/null +++ b/pkgs/glob/analysis_options.yaml
@@ -0,0 +1,10 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + +linter: + rules: + - avoid_unused_constructor_parameters + - cancel_subscriptions
diff --git a/pkgs/glob/lib/glob.dart b/pkgs/glob/lib/glob.dart new file mode 100644 index 0000000..f5e4bb4 --- /dev/null +++ b/pkgs/glob/lib/glob.dart
@@ -0,0 +1,203 @@ +// Copyright (c) 2014, 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 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:path/path.dart' as p; + +import 'src/ast.dart'; +import 'src/list_tree.dart'; +import 'src/parser.dart'; +import 'src/utils.dart'; + +/// Regular expression used to quote globs. +final _quoteRegExp = RegExp(r'[*{[?\\}\],\-()]'); + +/// A glob for matching and listing files and directories. +/// +/// A glob matches an entire string as a path. Although the glob pattern uses +/// POSIX syntax, it can match against POSIX, Windows, or URL paths. The format +/// it expects paths to use is based on the `context` parameter to [Glob.new]; +/// it defaults to the current system's syntax. +/// +/// Paths are normalized before being matched against a glob, so for example the +/// glob `foo/bar` matches the path `foo/./bar`. A relative glob can match an +/// absolute path and vice versa; globs and paths are both interpreted as +/// relative to `context.current`, which defaults to the current working +/// directory. +/// +/// When used as a [Pattern], a glob will return either one or zero matches for +/// a string depending on whether the entire string matches the glob. These +/// matches don't currently have capture groups, although this may change in the +/// future. +class Glob implements Pattern { + /// The pattern used to create this glob. + final String pattern; + + /// The context in which paths matched against this glob are interpreted. + final p.Context context; + + /// If true, a path matches if it matches the glob itself or is recursively + /// contained within a directory that matches. + final bool recursive; + + /// Whether the glob matches paths case-sensitively. + bool get caseSensitive => _ast.caseSensitive; + + /// The parsed AST of the glob. + final AstNode _ast; + + /// The underlying object used to implement [listFileSystem] and + /// [listFileSystemSync]. + /// + /// This should not be read directly outside of [_listTreeForFileSystem]. + ListTree? _listTree; + + /// Keeps track of the previous file system used. If this changes then the + /// [_listTree] must be invalidated. + /// + /// This is handled inside of [_listTreeForFileSystem]. + FileSystem? _previousFileSystem; + + /// Whether [context]'s current directory is absolute. + bool get _contextIsAbsolute => + _contextIsAbsoluteCache ??= context.isAbsolute(context.current); + + bool? _contextIsAbsoluteCache; + + /// Whether [pattern] could match absolute paths. + bool get _patternCanMatchAbsolute => + _patternCanMatchAbsoluteCache ??= _ast.canMatchAbsolute; + + bool? _patternCanMatchAbsoluteCache; + + /// Whether [pattern] could match relative paths. + bool get _patternCanMatchRelative => + _patternCanMatchRelativeCache ??= _ast.canMatchRelative; + + bool? _patternCanMatchRelativeCache; + + /// Returns [contents] with characters that are meaningful in globs + /// backslash-escaped. + static String quote(String contents) => + contents.replaceAllMapped(_quoteRegExp, (match) => '\\${match[0]}'); + + /// Creates a new glob with [pattern]. + /// + /// Paths matched against the glob are interpreted according to [context]. It + /// defaults to the system context. + /// + /// If [recursive] is true, this glob matches and lists not only the files and + /// directories it explicitly matches, but anything beneath those as well. + /// + /// If [caseSensitive] is true, this glob matches and lists only files whose + /// case matches that of the characters in the glob. Otherwise, it matches + /// regardless of case. This defaults to `false` when [context] is Windows and + /// `true` otherwise. + factory Glob(String pattern, + {p.Context? context, bool recursive = false, bool? caseSensitive}) { + context ??= p.context; + caseSensitive ??= context.style == p.Style.windows ? false : true; + if (recursive) pattern += '{,/**}'; + + var parser = Parser(pattern, context, caseSensitive: caseSensitive); + return Glob._(pattern, context, parser.parse(), recursive); + } + + Glob._(this.pattern, this.context, this._ast, this.recursive); + + /// Lists all [FileSystemEntity]s beneath [root] that match the glob in the + /// provided [fileSystem]. + /// + /// This works much like [Directory.list], but it only lists directories that + /// could contain entities that match the glob. It provides no guarantees + /// about the order of the returned entities, although it does guarantee that + /// only one entity with a given path will be returned. + /// + /// [root] defaults to the current working directory. + /// + /// [followLinks] works the same as for [Directory.list]. + Stream<FileSystemEntity> listFileSystem(FileSystem fileSystem, + {String? root, bool followLinks = true}) { + if (context.style != p.style) { + throw StateError("Can't list glob \"$this\"; it matches " + '${context.style} paths, but this platform uses ${p.style} paths.'); + } + + return _listTreeForFileSystem(fileSystem) + .list(root: root, followLinks: followLinks); + } + + /// Synchronously lists all [FileSystemEntity]s beneath [root] that match the + /// glob in the provided [fileSystem]. + /// + /// This works much like [Directory.listSync], but it only lists directories + /// that could contain entities that match the glob. It provides no guarantees + /// about the order of the returned entities, although it does guarantee that + /// only one entity with a given path will be returned. + /// + /// [root] defaults to the current working directory. + /// + /// [followLinks] works the same as for [Directory.list]. + List<FileSystemEntity> listFileSystemSync(FileSystem fileSystem, + {String? root, bool followLinks = true}) { + if (context.style != p.style) { + throw StateError("Can't list glob \"$this\"; it matches " + '${context.style} paths, but this platform uses ${p.style} paths.'); + } + + return _listTreeForFileSystem(fileSystem) + .listSync(root: root, followLinks: followLinks); + } + + /// Returns whether this glob matches [path]. + bool matches(String path) => matchAsPrefix(path) != null; + + @override + Match? matchAsPrefix(String path, [int start = 0]) { + // Globs are like anchored RegExps in that they only match entire paths, so + // if the match starts anywhere after the first character it can't succeed. + if (start != 0) return null; + + if (_patternCanMatchAbsolute && + (_contextIsAbsolute || context.isAbsolute(path))) { + var absolutePath = context.normalize(context.absolute(path)); + if (_ast.matches(toPosixPath(context, absolutePath))) { + return GlobMatch(path, this); + } + } + + if (_patternCanMatchRelative) { + var relativePath = context.relative(path); + if (_ast.matches(toPosixPath(context, relativePath))) { + return GlobMatch(path, this); + } + } + + return null; + } + + @override + Iterable<Match> allMatches(String path, [int start = 0]) { + var match = matchAsPrefix(path, start); + return match == null ? [] : [match]; + } + + @override + String toString() => pattern; + + /// Handles getting a possibly cached [ListTree] for a [fileSystem]. + ListTree _listTreeForFileSystem(FileSystem fileSystem) { + // Don't use cached trees for in memory file systems to avoid memory leaks. + if (fileSystem is MemoryFileSystem) return ListTree(_ast, fileSystem); + + // Throw away our cached `_listTree` if the file system is different. + if (fileSystem != _previousFileSystem) { + _listTree = null; + _previousFileSystem = fileSystem; + } + + return _listTree ??= ListTree(_ast, fileSystem); + } +}
diff --git a/pkgs/glob/lib/list_local_fs.dart b/pkgs/glob/lib/list_local_fs.dart new file mode 100644 index 0000000..6ccbde0 --- /dev/null +++ b/pkgs/glob/lib/list_local_fs.dart
@@ -0,0 +1,24 @@ +// Copyright (c) 2020, 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 'package:file/file.dart'; +import 'package:file/local.dart'; + +import 'glob.dart'; + +/// Platform specific extensions for where `dart:io` exists, which use the +/// local file system. +extension ListLocalFileSystem on Glob { + /// Convenience method for [Glob.listFileSystem] which uses the local file + /// system. + Stream<FileSystemEntity> list({String? root, bool followLinks = true}) => + listFileSystem(const LocalFileSystem(), + root: root, followLinks: followLinks); + + /// Convenience method for [Glob.listFileSystemSync] which uses the local + /// file system. + List<FileSystemEntity> listSync({String? root, bool followLinks = true}) => + listFileSystemSync(const LocalFileSystem(), + root: root, followLinks: followLinks); +}
diff --git a/pkgs/glob/lib/src/ast.dart b/pkgs/glob/lib/src/ast.dart new file mode 100644 index 0000000..179b081 --- /dev/null +++ b/pkgs/glob/lib/src/ast.dart
@@ -0,0 +1,439 @@ +// Copyright (c) 2014, 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 'package:collection/collection.dart'; +import 'package:path/path.dart' as p; + +import 'utils.dart'; + +const _separator = 0x2F; // "/" + +/// A node in the abstract syntax tree for a glob. +abstract class AstNode { + /// The cached regular expression that this AST was compiled into. + RegExp? _regExp; + + /// Whether this node matches case-sensitively or not. + final bool caseSensitive; + + /// Whether this glob could match an absolute path. + /// + /// Either this or [canMatchRelative] or both will be true. + bool get canMatchAbsolute => false; + + /// Whether this glob could match a relative path. + /// + /// Either this or [canMatchRelative] or both will be true. + bool get canMatchRelative => true; + + AstNode._(this.caseSensitive); + + /// Returns a new glob with all the options bubbled to the top level. + /// + /// In particular, this returns a glob AST with two guarantees: + /// + /// 1. There are no [OptionsNode]s other than the one at the top level. + /// 2. It matches the same set of paths as `this`. + /// + /// For example, given the glob `{foo,bar}/{click/clack}`, this would return + /// `{foo/click,foo/clack,bar/click,bar/clack}`. + OptionsNode flattenOptions() => OptionsNode([ + SequenceNode([this], caseSensitive: caseSensitive) + ], caseSensitive: caseSensitive); + + /// Returns whether this glob matches [string]. + bool matches(String string) => + (_regExp ??= RegExp('^${_toRegExp()}\$', caseSensitive: caseSensitive)) + .hasMatch(string); + + /// Subclasses should override this to return a regular expression component. + String _toRegExp(); +} + +/// A sequence of adjacent AST nodes. +class SequenceNode extends AstNode { + /// The nodes in the sequence. + final List<AstNode> nodes; + + @override + bool get canMatchAbsolute => nodes.first.canMatchAbsolute; + + @override + bool get canMatchRelative => nodes.first.canMatchRelative; + + SequenceNode(Iterable<AstNode> nodes, {bool caseSensitive = true}) + : nodes = nodes.toList(), + super._(caseSensitive); + + @override + OptionsNode flattenOptions() { + if (nodes.isEmpty) { + return OptionsNode([this], caseSensitive: caseSensitive); + } + + var sequences = + nodes.first.flattenOptions().options.map((sequence) => sequence.nodes); + for (var node in nodes.skip(1)) { + // Concatenate all sequences in the next options node ([nextSequences]) + // onto all previous sequences ([sequences]). + var nextSequences = node.flattenOptions().options; + sequences = sequences.expand((sequence) { + return nextSequences.map((nextSequence) { + return sequence.toList()..addAll(nextSequence.nodes); + }); + }); + } + + return OptionsNode(sequences.map((sequence) { + // Combine any adjacent LiteralNodes in [sequence]. + return SequenceNode( + sequence.fold<List<AstNode>>([], (combined, node) { + if (combined.isEmpty || + combined.last is! LiteralNode || + node is! LiteralNode) { + return combined..add(node); + } + + combined[combined.length - 1] = LiteralNode( + (combined.last as LiteralNode).text + node.text, + caseSensitive: caseSensitive); + return combined; + }), + caseSensitive: caseSensitive); + }), caseSensitive: caseSensitive); + } + + /// Splits this glob into components along its path separators. + /// + /// For example, given the glob `foo/*/*.dart`, this would return three globs: + /// `foo`, `*`, and `*.dart`. + /// + /// Path separators within options nodes are not split. For example, + /// `foo/{bar,baz/bang}/qux` will return three globs: `foo`, `{bar,baz/bang}`, + /// and `qux`. + /// + /// [context] is used to determine what absolute roots look like for this + /// glob. + List<SequenceNode> split(p.Context context) { + var componentsToReturn = <SequenceNode>[]; + List<AstNode>? currentComponent; + + void addNode(AstNode node) { + (currentComponent ??= []).add(node); + } + + void finishComponent() { + if (currentComponent == null) return; + componentsToReturn + .add(SequenceNode(currentComponent!, caseSensitive: caseSensitive)); + currentComponent = null; + } + + for (var node in nodes) { + if (node is! LiteralNode) { + addNode(node); + continue; + } + + if (!node.text.contains('/')) { + addNode(node); + continue; + } + + var text = node.text; + if (context.style == p.Style.windows) text = text.replaceAll('/', '\\'); + Iterable<String> components = context.split(text); + + // If the first component is absolute, that means it's a separator (on + // Windows some non-separator things are also absolute, but it's invalid + // to have "C:" show up in the middle of a path anyway). + if (context.isAbsolute(components.first)) { + // If this is the first component, it's the root. + if (componentsToReturn.isEmpty && currentComponent == null) { + var root = components.first; + if (context.style == p.Style.windows) { + // Above, we switched to backslashes to make [context.split] handle + // roots properly. That means that if there is a root, it'll still + // have backslashes, where forward slashes are required for globs. + // So we switch it back here. + root = root.replaceAll('\\', '/'); + } + addNode(LiteralNode(root, caseSensitive: caseSensitive)); + } + finishComponent(); + components = components.skip(1); + if (components.isEmpty) continue; + } + + // For each component except the last one, add a separate sequence to + // [sequences] containing only that component. + for (var component in components.take(components.length - 1)) { + addNode(LiteralNode(component, caseSensitive: caseSensitive)); + finishComponent(); + } + + // For the final component, only end its sequence (by adding a new empty + // sequence) if it ends with a separator. + addNode(LiteralNode(components.last, caseSensitive: caseSensitive)); + if (node.text.endsWith('/')) finishComponent(); + } + + finishComponent(); + return componentsToReturn; + } + + @override + String _toRegExp() => nodes.map((node) => node._toRegExp()).join(); + + @override + bool operator ==(Object other) => + other is SequenceNode && + const IterableEquality<AstNode>().equals(nodes, other.nodes); + + @override + int get hashCode => const IterableEquality<AstNode>().hash(nodes); + + @override + String toString() => nodes.join(); +} + +/// A node matching zero or more non-separator characters. +class StarNode extends AstNode { + StarNode({bool caseSensitive = true}) : super._(caseSensitive); + + @override + String _toRegExp() => '[^/]*'; + + @override + bool operator ==(Object other) => other is StarNode; + + @override + int get hashCode => 0; + + @override + String toString() => '*'; +} + +/// A node matching zero or more characters that may be separators. +class DoubleStarNode extends AstNode { + /// The path context for the glob. + /// + /// This is used to determine what absolute paths look like. + final p.Context _context; + + DoubleStarNode(this._context, {bool caseSensitive = true}) + : super._(caseSensitive); + + @override + String _toRegExp() { + // Double star shouldn't match paths with a leading "../", since these paths + // wouldn't be listed with this glob. We only check for "../" at the + // beginning since the paths are normalized before being checked against the + // glob. + var buffer = StringBuffer()..write(r'(?!^(?:\.\./|'); + + // A double star at the beginning of the glob also shouldn't match absolute + // paths, since those also wouldn't be listed. Which root patterns we look + // for depends on the style of path we're matching. + if (_context.style == p.Style.posix) { + buffer.write(r'/'); + } else if (_context.style == p.Style.windows) { + buffer.write(r'//|[A-Za-z]:/'); + } else { + assert(_context.style == p.Style.url); + buffer.write(r'[a-zA-Z][-+.a-zA-Z\d]*://|/'); + } + + // Use `[^]` rather than `.` so that it matches newlines as well. + buffer.write(r'))[^]*'); + + return buffer.toString(); + } + + @override + bool operator ==(Object other) => other is DoubleStarNode; + + @override + int get hashCode => 1; + + @override + String toString() => '**'; +} + +/// A node matching a single non-separator character. +class AnyCharNode extends AstNode { + AnyCharNode({bool caseSensitive = true}) : super._(caseSensitive); + + @override + String _toRegExp() => '[^/]'; + + @override + bool operator ==(Object other) => other is AnyCharNode; + + @override + int get hashCode => 2; + + @override + String toString() => '?'; +} + +/// A node matching a single character in a range of options. +class RangeNode extends AstNode { + /// The ranges matched by this node. + /// + /// The ends of the ranges are unicode code points. + final Set<Range> ranges; + + /// Whether this range was negated. + final bool negated; + + RangeNode(Iterable<Range> ranges, + {required this.negated, bool caseSensitive = true}) + : ranges = ranges.toSet(), + super._(caseSensitive); + + @override + OptionsNode flattenOptions() { + if (negated || ranges.any((range) => !range.isSingleton)) { + return super.flattenOptions(); + } + + // If a range explicitly lists a set of characters, return each character as + // a separate expansion. + return OptionsNode(ranges.map((range) { + return SequenceNode([ + LiteralNode(String.fromCharCodes([range.min]), + caseSensitive: caseSensitive) + ], caseSensitive: caseSensitive); + }), caseSensitive: caseSensitive); + } + + @override + String _toRegExp() { + var buffer = StringBuffer(); + + var containsSeparator = ranges.any((range) => range.contains(_separator)); + if (!negated && containsSeparator) { + // Add `(?!/)` because ranges are never allowed to match separators. + buffer.write('(?!/)'); + } + + buffer.write('['); + if (negated) { + buffer.write('^'); + // If the range doesn't itself exclude separators, exclude them ourselves, + // since ranges are never allowed to match them. + if (!containsSeparator) buffer.write('/'); + } + + for (var range in ranges) { + var start = String.fromCharCodes([range.min]); + buffer.write(regExpQuote(start)); + if (range.isSingleton) continue; + buffer.write('-'); + buffer.write(regExpQuote(String.fromCharCodes([range.max]))); + } + + buffer.write(']'); + return buffer.toString(); + } + + @override + bool operator ==(Object other) => + other is RangeNode && + other.negated == negated && + const SetEquality<Range>().equals(ranges, other.ranges); + + @override + int get hashCode => + (negated ? 1 : 3) * const SetEquality<Range>().hash(ranges); + + @override + String toString() { + var buffer = StringBuffer()..write('['); + for (var range in ranges) { + buffer.writeCharCode(range.min); + if (range.isSingleton) continue; + buffer.write('-'); + buffer.writeCharCode(range.max); + } + buffer.write(']'); + return buffer.toString(); + } +} + +/// A node that matches one of several options. +class OptionsNode extends AstNode { + /// The options to match. + final List<SequenceNode> options; + + @override + bool get canMatchAbsolute => options.any((node) => node.canMatchAbsolute); + + @override + bool get canMatchRelative => options.any((node) => node.canMatchRelative); + + OptionsNode(Iterable<SequenceNode> options, {bool caseSensitive = true}) + : options = options.toList(), + super._(caseSensitive); + + @override + OptionsNode flattenOptions() => + OptionsNode(options.expand((option) => option.flattenOptions().options), + caseSensitive: caseSensitive); + + @override + String _toRegExp() => + '(?:${options.map((option) => option._toRegExp()).join("|")})'; + + @override + bool operator ==(Object other) => + other is OptionsNode && + const UnorderedIterableEquality<SequenceNode>() + .equals(options, other.options); + + @override + int get hashCode => + const UnorderedIterableEquality<SequenceNode>().hash(options); + + @override + String toString() => '{${options.join(',')}}'; +} + +/// A node that matches a literal string. +class LiteralNode extends AstNode { + /// The string to match. + final String text; + + /// The path context for the glob. + /// + /// This is used to determine whether this could match an absolute path. + final p.Context? _context; + + @override + bool get canMatchAbsolute { + var nativeText = + _context!.style == p.Style.windows ? text.replaceAll('/', '\\') : text; + return _context.isAbsolute(nativeText); + } + + @override + bool get canMatchRelative => !canMatchAbsolute; + + LiteralNode(this.text, {p.Context? context, bool caseSensitive = true}) + : _context = context, + super._(caseSensitive); + + @override + String _toRegExp() => regExpQuote(text); + + @override + bool operator ==(Object other) => other is LiteralNode && other.text == text; + + @override + int get hashCode => text.hashCode; + + @override + String toString() => text; +}
diff --git a/pkgs/glob/lib/src/list_tree.dart b/pkgs/glob/lib/src/list_tree.dart new file mode 100644 index 0000000..2a6c020 --- /dev/null +++ b/pkgs/glob/lib/src/list_tree.dart
@@ -0,0 +1,514 @@ +// Copyright (c) 2014, 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:async'; + +import 'package:async/async.dart'; +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'ast.dart'; +import 'utils.dart'; + +/// The errno for a file or directory not existing on Mac and Linux. +const _enoent = 2; + +/// Another errno we see on Windows when trying to list a non-existent +/// directory. +const _enoentWin = 3; + +/// A structure built from a glob that efficiently lists filesystem entities +/// that match that glob. +/// +/// This structure is designed to list the minimal number of physical +/// directories necessary to find everything that matches the glob. For example, +/// for the glob `foo/{bar,baz}/*`, there's no need to list the working +/// directory or even `foo/`; only `foo/bar` and `foo/baz` should be listed. +/// +/// This works by creating a tree of [_ListTreeNode]s, each of which corresponds +/// to a single of directory nesting in the source glob. Each node has child +/// nodes associated with globs ([_ListTreeNode.children]), as well as its own +/// glob ([_ListTreeNode._validator]) that indicates which entities within that +/// node's directory should be returned. +/// +/// For example, the glob `foo/{*.dart,b*/*.txt}` creates the following tree: +/// +/// . +/// '-- "foo" (validator: "*.dart") +/// '-- "b*" (validator: "*.txt" +/// +/// If a node doesn't have a validator, we know we don't have to list it +/// explicitly. +/// +/// Nodes can also be marked as "recursive", which means they need to be listed +/// recursively (usually to support `**`). In this case, they will have no +/// children; instead, their validator will just encompass the globs that would +/// otherwise be in their children. For example, the glob +/// `foo/{**.dart,bar/*.txt}` creates a recursive node for `foo` with the +/// validator `**.dart,bar/*.txt`. +/// +/// If the glob contains multiple filesystem roots (e.g. `{C:/,D:/}*.dart`), +/// each root will have its own tree of nodes. Relative globs use `.` as their +/// root instead. +class ListTree { + /// A map from filesystem roots to the list tree for those roots. + /// + /// A relative glob will use `.` as its root. + final Map<String, _ListTreeNode> _trees; + + /// Whether paths listed might overlap. + /// + /// If they do, we need to filter out overlapping paths. + final bool _canOverlap; + + /// The file system to operate on. + final FileSystem _fileSystem; + + ListTree._(this._trees, this._fileSystem) + : _canOverlap = _computeCanOverlap(_trees); + + factory ListTree(AstNode glob, FileSystem fileSystem) { + // The first step in constructing a tree from the glob is to simplify the + // problem by eliminating options. [glob.flattenOptions] bubbles all options + // (and certain ranges) up to the top level of the glob so we can deal with + // them one at a time. + var options = glob.flattenOptions(); + var trees = <String, _ListTreeNode>{}; + + for (var option in options.options) { + // Since each option doesn't include its own options, we can safely split + // it into path components. + var components = option.split(p.context); + var firstNode = components.first.nodes.first; + var root = '.'; + + // Determine the root for this option, if it's absolute. If it's not, the + // root's just ".". + if (firstNode is LiteralNode) { + var text = firstNode.text; + // Platform agnostic way of checking for Windows without `dart:io`. + if (p.context == p.windows) text.replaceAll('/', '\\'); + if (p.isAbsolute(text)) { + // If the path is absolute, the root should be the only thing in the + // first component. + assert(components.first.nodes.length == 1); + root = firstNode.text; + components.removeAt(0); + } + } + + _addGlob(root, components, trees); + } + + return ListTree._(trees, fileSystem); + } + + /// Add the glob represented by [components] to the tree under [root]. + static void _addGlob(String root, List<SequenceNode> components, + Map<String, _ListTreeNode> trees) { + // The first [parent] represents the root directory itself. It may be null + // here if this is the first option with this particular [root]. If so, + // we'll create it below. + // + // As we iterate through [components], [parent] will be set to + // progressively more nested nodes. + var parent = trees[root]; + for (var i = 0; i < components.length; i++) { + var component = components[i]; + var recursive = component.nodes.any((node) => node is DoubleStarNode); + var complete = i == components.length - 1; + + // If the parent node for this level of nesting already exists, the new + // option will be added to it as additional validator options and/or + // additional children. + // + // If the parent doesn't exist, we'll create it in one of the else + // clauses below. + if (parent != null) { + if (parent.isRecursive || recursive) { + // If [component] is recursive, mark [parent] as recursive. This + // will cause all of its children to be folded into its validator. + // If [parent] was already recursive, this is a no-op. + parent.makeRecursive(); + + // Add [component] and everything nested beneath it as an option to + // [parent]. Since [parent] is recursive, it will recursively list + // everything beneath it and filter them with one big glob. + parent.addOption(_join(components.sublist(i))); + return; + } else if (complete) { + // If [component] is the last component, add it to [parent]'s + // validator but not to its children. + parent.addOption(component); + } else { + // On the other hand if there are more components, add [component] + // to [parent]'s children and not its validator. Since we process + // each option's components separately, the same component is never + // both a validator and a child. + var children = parent.children!; + if (!children.containsKey(component)) { + children[component] = _ListTreeNode(); + } + parent = children[component]; + } + } else if (recursive) { + trees[root] = _ListTreeNode.recursive(_join(components.sublist(i))); + return; + } else if (complete) { + trees[root] = _ListTreeNode()..addOption(component); + } else { + var rootNode = _ListTreeNode(); + trees[root] = rootNode; + var rootChildren = rootNode.children!; + rootChildren[component] = _ListTreeNode(); + parent = rootChildren[component]; + } + } + } + + /// Computes the value for [_canOverlap]. + static bool _computeCanOverlap(Map<String, _ListTreeNode> trees) { + // If this can list a relative path and an absolute path, the former may be + // contained within the latter. + if (trees.length > 1 && trees.containsKey('.')) return true; + + // Otherwise, this can only overlap if the tree beneath any given root could + // overlap internally. + return trees.values.any((node) => node.canOverlap); + } + + /// List all entities that match this glob beneath [root]. + Stream<FileSystemEntity> list({String? root, bool followLinks = true}) { + root ??= '.'; + var group = StreamGroup<FileSystemEntity>(); + for (var rootDir in _trees.keys) { + var dir = rootDir == '.' ? root : rootDir; + group.add( + _trees[rootDir]!.list(dir, _fileSystem, followLinks: followLinks)); + } + group.close(); + + if (!_canOverlap) return group.stream; + + // TODO: Rather than filtering here, avoid double-listing directories + // in the first place. + var seen = <String>{}; + return group.stream.where((entity) => seen.add(entity.path)); + } + + /// Synchronosuly list all entities that match this glob beneath [root]. + List<FileSystemEntity> listSync({String? root, bool followLinks = true}) { + root ??= '.'; + var result = _trees.keys.expand((rootDir) { + var dir = rootDir == '.' ? root! : rootDir; + return _trees[rootDir]! + .listSync(dir, _fileSystem, followLinks: followLinks); + }); + + if (!_canOverlap) return result.toList(); + + // TODO: Rather than filtering here, avoid double-listing directories + // in the first place. + var seen = <String>{}; + return result.where((entity) => seen.add(entity.path)).toList(); + } +} + +/// A single node in a [ListTree]. +class _ListTreeNode { + /// This node's child nodes, by their corresponding globs. + /// + /// Each child node will only be listed on directories that match its glob. + /// + /// This may be `null`, indicating that this node should be listed + /// recursively. + Map<SequenceNode, _ListTreeNode>? children; + + /// This node's validator. + /// + /// This determines which entities will ultimately be emitted when [list] is + /// called. + OptionsNode? _validator; + + /// Whether this node is recursive. + /// + /// A recursive node has no children and is listed recursively. + bool get isRecursive => children == null; + + bool get _caseSensitive { + if (_validator != null) return _validator!.caseSensitive; + if (children?.isEmpty != false) return true; + return children!.keys.first.caseSensitive; + } + + /// Whether this node doesn't itself need to be listed. + /// + /// If a node has no validator and all of its children are literal filenames, + /// there's no need to list its contents. We can just directly traverse into + /// its children. + bool get _isIntermediate { + if (_validator != null) return false; + return children!.keys.every((sequence) => + sequence.nodes.length == 1 && sequence.nodes.first is LiteralNode); + } + + /// Returns whether listing this node might return overlapping results. + bool get canOverlap { + // A recusive node can never overlap with itself, because it will only ever + // involve a single call to [Directory.list] that's then filtered with + // [_validator]. + if (isRecursive) return false; + + // If there's more than one child node and at least one of the children is + // dynamic (that is, matches more than just a literal string), there may be + // overlap. + if (children!.length > 1) { + // Case-insensitivity means that even literals may match multiple entries. + if (!_caseSensitive) return true; + + if (children!.keys.any((sequence) => + sequence.nodes.length > 1 || sequence.nodes.single is! LiteralNode)) { + return true; + } + } + + return children!.values.any((node) => node.canOverlap); + } + + /// Creates a node with no children and no validator. + _ListTreeNode() + : children = <SequenceNode, _ListTreeNode>{}, + _validator = null; + + /// Creates a recursive node the given [validator]. + _ListTreeNode.recursive(SequenceNode validator) + : children = null, + _validator = + OptionsNode([validator], caseSensitive: validator.caseSensitive); + + /// Transforms this into recursive node, folding all its children into its + /// validator. + void makeRecursive() { + if (isRecursive) return; + var children = this.children!; + _validator = OptionsNode(children.entries.map((entry) { + entry.value.makeRecursive(); + return _join([entry.key, entry.value._validator!]); + }), caseSensitive: _caseSensitive); + this.children = null; + } + + /// Adds [validator] to this node's existing validator. + void addOption(SequenceNode validator) { + if (_validator == null) { + _validator = + OptionsNode([validator], caseSensitive: validator.caseSensitive); + } else { + _validator!.options.add(validator); + } + } + + /// Lists all entities within [dir] matching this node or its children. + /// + /// This may return duplicate entities. These will be filtered out in + /// [ListTree.list]. + Stream<FileSystemEntity> list(String dir, FileSystem fileSystem, + {bool followLinks = true}) { + if (isRecursive) { + return fileSystem + .directory(dir) + .list(recursive: true, followLinks: followLinks) + .ignoreMissing() + .where((entity) => _matches(p.relative(entity.path, from: dir))); + } + + // Don't spawn extra [Directory.list] calls when we already know exactly + // which subdirectories we're interested in. + if (_isIntermediate && _caseSensitive) { + var resultGroup = StreamGroup<FileSystemEntity>(); + children!.forEach((sequence, child) { + resultGroup.add(child.list( + p.join(dir, (sequence.nodes.single as LiteralNode).text), + fileSystem, + followLinks: followLinks)); + }); + resultGroup.close(); + return resultGroup.stream; + } + + return StreamCompleter.fromFuture(() async { + var entities = await fileSystem + .directory(dir) + .list(followLinks: followLinks) + .ignoreMissing() + .toList(); + await _validateIntermediateChildrenAsync(dir, entities, fileSystem); + + var resultGroup = StreamGroup<FileSystemEntity>(); + var resultController = StreamController<FileSystemEntity>(sync: true); + unawaited(resultGroup.add(resultController.stream)); + for (var entity in entities) { + var basename = p.relative(entity.path, from: dir); + if (_matches(basename)) resultController.add(entity); + + children!.forEach((sequence, child) { + if (entity is! Directory) return; + if (!sequence.matches(basename)) return; + var stream = child.list(p.join(dir, basename), fileSystem, + followLinks: followLinks); + resultGroup.add(stream); + }); + } + unawaited(resultController.close()); + unawaited(resultGroup.close()); + return resultGroup.stream; + }()); + } + + /// If this is a case-insensitive list, validates that all intermediate + /// children (according to [_isIntermediate]) match at least one entity in + /// [entities]. + /// + /// This ensures that listing "foo/bar/*" fails on case-sensitive systems if + /// "foo/bar" doesn't exist. + Future _validateIntermediateChildrenAsync(String dir, + List<FileSystemEntity> entities, FileSystem fileSystem) async { + if (_caseSensitive) return; + + for (var entry in children!.entries) { + var child = entry.value; + var sequence = entry.key; + if (!child._isIntermediate) continue; + if (entities.any( + (entity) => sequence.matches(p.relative(entity.path, from: dir)))) { + continue; + } + + // We know this will fail, we're just doing it to force dart:io to emit + // the exception it would if we were listing case-sensitively. + await child + .list(p.join(dir, (sequence.nodes.single as LiteralNode).text), + fileSystem) + .toList(); + } + } + + /// Synchronously lists all entities within [dir] matching this node or its + /// children. + /// + /// This may return duplicate entities. These will be filtered out in + /// [ListTree.listSync]. + Iterable<FileSystemEntity> listSync(String dir, FileSystem fileSystem, + {bool followLinks = true}) { + if (isRecursive) { + try { + return fileSystem + .directory(dir) + .listSync(recursive: true, followLinks: followLinks) + .where((entity) => _matches(p.relative(entity.path, from: dir))); + } on FileSystemException catch (error) { + if (error.isMissing) return const []; + rethrow; + } + } + + // Don't spawn extra [Directory.listSync] calls when we already know exactly + // which subdirectories we're interested in. + if (_isIntermediate && _caseSensitive) { + return children!.entries.expand((entry) { + var sequence = entry.key; + var child = entry.value; + return child.listSync( + p.join(dir, (sequence.nodes.single as LiteralNode).text), + fileSystem, + followLinks: followLinks); + }); + } + + List<FileSystemEntity> entities; + try { + entities = fileSystem.directory(dir).listSync(followLinks: followLinks); + } on FileSystemException catch (error) { + if (error.isMissing) return const []; + rethrow; + } + _validateIntermediateChildrenSync(dir, entities, fileSystem); + + return entities.expand((entity) { + var entities = <FileSystemEntity>[]; + var basename = p.relative(entity.path, from: dir); + if (_matches(basename)) entities.add(entity); + if (entity is! Directory) return entities; + + entities.addAll(children!.keys + .where((sequence) => sequence.matches(basename)) + .expand((sequence) { + return children![sequence]! + .listSync(p.join(dir, basename), fileSystem, + followLinks: followLinks) + .toList(); + })); + + return entities; + }); + } + + /// If this is a case-insensitive list, validates that all intermediate + /// children (according to [_isIntermediate]) match at least one entity in + /// [entities]. + /// + /// This ensures that listing "foo/bar/*" fails on case-sensitive systems if + /// "foo/bar" doesn't exist. + void _validateIntermediateChildrenSync( + String dir, List<FileSystemEntity> entities, FileSystem fileSystem) { + if (_caseSensitive) return; + + children!.forEach((sequence, child) { + if (!child._isIntermediate) return; + if (entities.any( + (entity) => sequence.matches(p.relative(entity.path, from: dir)))) { + return; + } + + // If there are no [entities] that match [sequence], manually list the + // directory to force `dart:io` to throw an error. This allows us to + // ensure that listing "foo/bar/*" fails on case-sensitive systems if + // "foo/bar" doesn't exist. + child.listSync( + p.join(dir, (sequence.nodes.single as LiteralNode).text), fileSystem); + }); + } + + /// Returns whether the native [path] matches [_validator]. + bool _matches(String path) => + _validator?.matches(toPosixPath(p.context, path)) ?? false; + + @override + String toString() => '($_validator) $children'; +} + +/// Joins each [components] into a new glob where each component is separated by +/// a path separator. +SequenceNode _join(Iterable<AstNode> components) { + var componentsList = components.toList(); + var first = componentsList.removeAt(0); + var nodes = [first]; + for (var component in componentsList) { + nodes.add(LiteralNode('/', caseSensitive: first.caseSensitive)); + nodes.add(component); + } + return SequenceNode(nodes, caseSensitive: first.caseSensitive); +} + +extension on Stream<FileSystemEntity> { + Stream<FileSystemEntity> ignoreMissing() => handleError((_) {}, + test: (error) => error is FileSystemException && error.isMissing); +} + +extension on FileSystemException { + bool get isMissing { + final errorCode = osError?.errorCode; + return errorCode == _enoent || errorCode == _enoentWin; + } +}
diff --git a/pkgs/glob/lib/src/parser.dart b/pkgs/glob/lib/src/parser.dart new file mode 100644 index 0000000..e76f7c5 --- /dev/null +++ b/pkgs/glob/lib/src/parser.dart
@@ -0,0 +1,176 @@ +// Copyright (c) 2014, 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 'package:path/path.dart' as p; +import 'package:string_scanner/string_scanner.dart'; + +import 'ast.dart'; +import 'utils.dart'; + +const _hyphen = 0x2D; +const _slash = 0x2F; + +/// A parser for globs. +class Parser { + /// The scanner used to scan the source. + final StringScanner _scanner; + + /// The path context for the glob. + final p.Context _context; + + /// Whether this glob is case-sensitive. + final bool _caseSensitive; + + Parser(String component, this._context, {bool caseSensitive = true}) + : _scanner = StringScanner(component), + _caseSensitive = caseSensitive; + + /// Parses an entire glob. + SequenceNode parse() => _parseSequence(); + + /// Parses a [SequenceNode]. + /// + /// If [inOptions] is true, this is parsing within an [OptionsNode]. + SequenceNode _parseSequence({bool inOptions = false}) { + var nodes = <AstNode>[]; + + if (_scanner.isDone) { + _scanner.error('expected a glob.', position: 0, length: 0); + } + + while (!_scanner.isDone) { + if (inOptions && (_scanner.matches(',') || _scanner.matches('}'))) break; + nodes.add(_parseNode(inOptions: inOptions)); + } + + return SequenceNode(nodes, caseSensitive: _caseSensitive); + } + + /// Parses an [AstNode]. + /// + /// If [inOptions] is true, this is parsing within an [OptionsNode]. + AstNode _parseNode({bool inOptions = false}) { + var star = _parseStar(); + if (star != null) return star; + + var anyChar = _parseAnyChar(); + if (anyChar != null) return anyChar; + + var range = _parseRange(); + if (range != null) return range; + + var options = _parseOptions(); + if (options != null) return options; + + return _parseLiteral(inOptions: inOptions); + } + + /// Tries to parse a [StarNode] or a [DoubleStarNode]. + /// + /// Returns `null` if there's not one to parse. + AstNode? _parseStar() { + if (!_scanner.scan('*')) return null; + return _scanner.scan('*') + ? DoubleStarNode(_context, caseSensitive: _caseSensitive) + : StarNode(caseSensitive: _caseSensitive); + } + + /// Tries to parse an [AnyCharNode]. + /// + /// Returns `null` if there's not one to parse. + AstNode? _parseAnyChar() { + if (!_scanner.scan('?')) return null; + return AnyCharNode(caseSensitive: _caseSensitive); + } + + /// Tries to parse an [RangeNode]. + /// + /// Returns `null` if there's not one to parse. + AstNode? _parseRange() { + if (!_scanner.scan('[')) return null; + if (_scanner.matches(']')) _scanner.error('unexpected "]".'); + var negated = _scanner.scan('!') || _scanner.scan('^'); + + int readRangeChar() { + var char = _scanner.readChar(); + if (negated || char != _slash) return char; + _scanner.error('"/" may not be used in a range.', + position: _scanner.position - 1); + } + + var ranges = <Range>[]; + while (!_scanner.scan(']')) { + var start = _scanner.position; + // Allow a backslash to escape a character. + _scanner.scan('\\'); + var char = readRangeChar(); + + if (_scanner.scan('-')) { + if (_scanner.matches(']')) { + ranges.add(Range.singleton(char)); + ranges.add(Range.singleton(_hyphen)); + continue; + } + + // Allow a backslash to escape a character. + _scanner.scan('\\'); + + var end = readRangeChar(); + + if (end < char) { + _scanner.error('Range out of order.', + position: start, length: _scanner.position - start); + } + ranges.add(Range(char, end)); + } else { + ranges.add(Range.singleton(char)); + } + } + + return RangeNode(ranges, negated: negated, caseSensitive: _caseSensitive); + } + + /// Tries to parse an [OptionsNode]. + /// + /// Returns `null` if there's not one to parse. + AstNode? _parseOptions() { + if (!_scanner.scan('{')) return null; + if (_scanner.matches('}')) _scanner.error('unexpected "}".'); + + var options = <SequenceNode>[]; + do { + options.add(_parseSequence(inOptions: true)); + } while (_scanner.scan(',')); + + // Don't allow single-option blocks. + if (options.length == 1) _scanner.expect(','); + _scanner.expect('}'); + + return OptionsNode(options, caseSensitive: _caseSensitive); + } + + /// Parses a [LiteralNode]. + AstNode _parseLiteral({bool inOptions = false}) { + // If we're in an options block, we want to stop parsing as soon as we hit a + // comma. Otherwise, commas are fair game for literals. + var regExp = RegExp(inOptions ? r'[^*{[?\\}\],()]*' : r'[^*{[?\\}\]()]*'); + + _scanner.scan(regExp); + var buffer = StringBuffer()..write(_scanner.lastMatch![0]); + + while (_scanner.scan('\\')) { + buffer.writeCharCode(_scanner.readChar()); + _scanner.scan(regExp); + buffer.write(_scanner.lastMatch![0]); + } + + for (var char in const [']', '(', ')']) { + if (_scanner.matches(char)) _scanner.error('unexpected "$char"'); + } + if (!inOptions && _scanner.matches('}')) _scanner.error('unexpected "}"'); + + return LiteralNode(buffer.toString(), + context: _context, caseSensitive: _caseSensitive); + } +}
diff --git a/pkgs/glob/lib/src/stream_pool.dart b/pkgs/glob/lib/src/stream_pool.dart new file mode 100644 index 0000000..8317ae6 --- /dev/null +++ b/pkgs/glob/lib/src/stream_pool.dart
@@ -0,0 +1,73 @@ +// Copyright (c) 2013, 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:async'; + +/// A pool of streams whose events are unified and emitted through a central +/// stream. +class StreamPool<T> { + /// The stream through which all events from streams in the pool are emitted. + Stream<T> get stream => _controller.stream; + final StreamController<T> _controller; + + /// Subscriptions to the streams that make up the pool. + final _subscriptions = <Stream<T>, StreamSubscription<T>>{}; + + /// Whether this pool should be closed when it becomes empty. + bool _closeWhenEmpty = false; + + /// Creates a new stream pool that only supports a single subscriber. + /// + /// Any events from broadcast streams in the pool will be buffered until a + /// listener is subscribed. + StreamPool() + // Create the controller as sync so that any sync input streams will be + // forwarded synchronously. Async input streams will have their asynchrony + // preserved, since _controller.add will be called asynchronously. + : _controller = StreamController<T>(sync: true); + + /// Creates a new stream pool where [stream] can be listened to more than + /// once. + /// + /// Any events from buffered streams in the pool will be emitted immediately, + /// regardless of whether [stream] has any subscribers. + StreamPool.broadcast() + // Create the controller as sync so that any sync input streams will be + // forwarded synchronously. Async input streams will have their asynchrony + // preserved, since _controller.add will be called asynchronously. + : _controller = StreamController<T>.broadcast(sync: true); + + /// Adds [stream] as a member of this pool. + /// + /// Any events from [stream] will be emitted through [this.stream]. If + /// [stream] is sync, they'll be emitted synchronously; if [stream] is async, + /// they'll be emitted asynchronously. + void add(Stream<T> stream) { + if (_subscriptions.containsKey(stream)) return; + _subscriptions[stream] = stream.listen(_controller.add, + onError: _controller.addError, onDone: () => remove(stream)); + } + + /// Removes [stream] as a member of this pool. + void remove(Stream<T> stream) { + var subscription = _subscriptions.remove(stream); + if (subscription != null) subscription.cancel(); + if (_closeWhenEmpty && _subscriptions.isEmpty) close(); + } + + /// Removes all streams from this pool and closes [stream]. + void close() { + for (var subscription in _subscriptions.values) { + subscription.cancel(); + } + _subscriptions.clear(); + _controller.close(); + } + + /// The next time this pool becomes empty, close it. + void closeWhenEmpty() { + if (_subscriptions.isEmpty) close(); + _closeWhenEmpty = true; + } +}
diff --git a/pkgs/glob/lib/src/utils.dart b/pkgs/glob/lib/src/utils.dart new file mode 100644 index 0000000..64bc974 --- /dev/null +++ b/pkgs/glob/lib/src/utils.dart
@@ -0,0 +1,85 @@ +// Copyright (c) 2014, 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 'package:path/path.dart' as p; + +/// A range from [min] to [max], inclusive. +class Range { + /// The minimum value included by the range. + final int min; + + /// The maximum value included by the range. + final int max; + + /// Whether this range covers only a single number. + bool get isSingleton => min == max; + + Range(this.min, this.max); + + /// Returns a range that covers only [value]. + Range.singleton(int value) : this(value, value); + + /// Whether `this` contains [value]. + bool contains(int value) => value >= min && value <= max; + + @override + bool operator ==(Object other) => + other is Range && other.min == min && other.max == max; + + @override + int get hashCode => 3 * min + 7 * max; +} + +/// An implementation of [Match] constructed by `Glob`s. +class GlobMatch implements Match { + @override + final String input; + @override + final Pattern pattern; + @override + final int start = 0; + + @override + int get end => input.length; + @override + int get groupCount => 0; + + GlobMatch(this.input, this.pattern); + + @override + String operator [](int group) => this.group(group); + + @override + String group(int group) { + if (group != 0) throw RangeError.range(group, 0, 0); + return input; + } + + @override + List<String> groups(List<int> groupIndices) => + groupIndices.map(group).toList(); +} + +final _quote = RegExp(r'[+*?{}|[\]\\().^$-]'); + +/// Returns [contents] with characters that are meaningful in regular +/// expressions backslash-escaped. +String regExpQuote(String contents) => + contents.replaceAllMapped(_quote, (char) => '\\${char[0]}'); + +/// Returns [path] with all its separators replaced with forward slashes. +/// +/// This is useful when converting from Windows paths to globs. +String separatorToForwardSlash(String path) { + if (p.style != p.Style.windows) return path; + return path.replaceAll('\\', '/'); +} + +/// Returns [path] which follows [context] converted to the POSIX format that +/// globs match against. +String toPosixPath(p.Context context, String path) { + if (context.style == p.Style.windows) return path.replaceAll('\\', '/'); + if (context.style == p.Style.url) return Uri.decodeFull(path); + return path; +}
diff --git a/pkgs/glob/pubspec.yaml b/pkgs/glob/pubspec.yaml new file mode 100644 index 0000000..7c86e31 --- /dev/null +++ b/pkgs/glob/pubspec.yaml
@@ -0,0 +1,20 @@ +name: glob +version: 2.1.3 +description: A library to perform Bash-style file and directory globbing. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/glob +issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aglob + +environment: + sdk: ^3.3.0 + +dependencies: + async: ^2.5.0 + collection: ^1.15.0 + file: '>=6.1.3 <8.0.0' + path: ^1.8.0 + string_scanner: ^1.1.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.0.0 + test: ^1.17.0 + test_descriptor: ^2.0.0
diff --git a/pkgs/glob/test/glob_test.dart b/pkgs/glob/test/glob_test.dart new file mode 100644 index 0000000..85c3fac --- /dev/null +++ b/pkgs/glob/test/glob_test.dart
@@ -0,0 +1,111 @@ +// Copyright (c) 2014, 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 'package:glob/glob.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('Glob.quote()', () { + test('quotes all active characters', () { + expect(Glob.quote('*{[?\\}],-'), equals(r'\*\{\[\?\\\}\]\,\-')); + }); + + test("doesn't quote inactive characters", () { + expect(Glob.quote('abc~`_+='), equals('abc~`_+=')); + }); + }); + + group('Glob.matches()', () { + test('returns whether the path matches the glob', () { + var glob = Glob('foo*'); + expect(glob.matches('foobar'), isTrue); + expect(glob.matches('baz'), isFalse); + }); + + test('only matches the entire path', () { + var glob = Glob('foo'); + expect(glob.matches('foo/bar'), isFalse); + expect(glob.matches('bar/foo'), isFalse); + }); + }); + + group('Glob.matchAsPrefix()', () { + test('returns a match if the path matches the glob', () { + var glob = Glob('foo*'); + expect(glob.matchAsPrefix('foobar'), isA<Match>()); + expect(glob.matchAsPrefix('baz'), isNull); + }); + + test('returns null for start > 0', () { + var glob = Glob('*'); + expect(glob.matchAsPrefix('foobar', 1), isNull); + }); + }); + + group('Glob.allMatches()', () { + test('returns a single match if the path matches the glob', () { + var matches = Glob('foo*').allMatches('foobar'); + expect(matches, hasLength(1)); + expect(matches.first, isA<Match>()); + }); + + test("returns an empty list if the path doesn't match the glob", () { + expect(Glob('foo*').allMatches('baz'), isEmpty); + }); + + test('returns no matches for start > 0', () { + var glob = Glob('*'); + expect(glob.allMatches('foobar', 1), isEmpty); + }); + }); + + group('GlobMatch', () { + var glob = Glob('foo*'); + var match = glob.matchAsPrefix('foobar')!; + + test('returns the string as input', () { + expect(match.input, equals('foobar')); + }); + + test('returns the glob as the pattern', () { + expect(match.pattern, equals(glob)); + }); + + test('returns the span of the string for start and end', () { + expect(match.start, equals(0)); + expect(match.end, equals('foobar'.length)); + }); + + test('has a single group that contains the whole string', () { + expect(match.groupCount, equals(0)); + expect(match[0], equals('foobar')); + expect(match.group(0), equals('foobar')); + expect(match.groups([0]), equals(['foobar'])); + }); + + test('throws a range error for an invalid group', () { + expect(() => match[1], throwsRangeError); + expect(() => match[-1], throwsRangeError); + expect(() => match.group(1), throwsRangeError); + expect(() => match.groups([1]), throwsRangeError); + }); + }); + + test('globs are case-sensitive by default for Posix and URL contexts', () { + expect('foo', contains(Glob('foo', context: p.posix))); + expect('FOO', isNot(contains(Glob('foo', context: p.posix)))); + expect('foo', isNot(contains(Glob('FOO', context: p.posix)))); + + expect('foo', contains(Glob('foo', context: p.url))); + expect('FOO', isNot(contains(Glob('foo', context: p.url)))); + expect('foo', isNot(contains(Glob('FOO', context: p.url)))); + }); + + test('globs are case-insensitive by default for Windows contexts', () { + expect('foo', contains(Glob('foo', context: p.windows))); + expect('FOO', contains(Glob('foo', context: p.windows))); + expect('foo', contains(Glob('FOO', context: p.windows))); + }); +}
diff --git a/pkgs/glob/test/list_test.dart b/pkgs/glob/test/list_test.dart new file mode 100644 index 0000000..e008d71 --- /dev/null +++ b/pkgs/glob/test/list_test.dart
@@ -0,0 +1,349 @@ +// Copyright (c) 2014, 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. + +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:glob/glob.dart'; +import 'package:glob/list_local_fs.dart'; +import 'package:glob/src/utils.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + setUp(() async { + await d.dir('foo', [ + d.file('bar'), + d.dir('baz', [d.file('bang'), d.file('qux')]) + ]).create(); + }); + + group('list()', () { + test("fails if the context doesn't match the system context", () { + expect(Glob('*', context: p.url).list, throwsStateError); + }); + + test('returns empty list for non-existent case-sensitive directories', + () async { + expect(await Glob('non/existent/**', caseSensitive: true).list().toList(), + <Never>[]); + }); + + test('returns empty list for non-existent case-insensitive directories', + () async { + expect( + await Glob('non/existent/**', caseSensitive: false).list().toList(), + <Never>[]); + }); + }); + + group('listSync()', () { + test("fails if the context doesn't match the system context", () { + expect(Glob('*', context: p.url).listSync, throwsStateError); + }); + + test('returns empty list for non-existent case-sensitive directories', () { + expect( + Glob('non/existent/**', caseSensitive: true).listSync(), <Never>[]); + }); + + test('returns empty list for non-existent case-insensitive directories', + () { + expect( + Glob('non/existent/**', caseSensitive: false).listSync(), <Never>[]); + }); + }); + + group('when case-sensitive', () { + test('lists literals case-sensitively', () { + expect(Glob('foo/BAZ/qux', caseSensitive: true).listSync(), <Never>[]); + }); + + test('lists ranges case-sensitively', () { + expect(Glob('foo/[BX][A-Z]z/qux', caseSensitive: true).listSync(), + <Never>[]); + }); + + test('options preserve case-sensitivity', () { + expect( + Glob('foo/{BAZ,ZAP}/qux', caseSensitive: true).listSync(), <Never>[]); + }); + }); + + syncAndAsync((ListFn list) { + group('literals', () { + test('lists a single literal', () async { + expect( + await list('foo/baz/qux'), equals([p.join('foo', 'baz', 'qux')])); + }); + + test('lists a non-matching literal', () async { + expect(await list('foo/baz/nothing'), isEmpty); + }); + }); + + group('star', () { + test('lists within filenames but not across directories', () async { + expect(await list('foo/b*'), + unorderedEquals([p.join('foo', 'bar'), p.join('foo', 'baz')])); + }); + + test('lists the empy string', () async { + expect(await list('foo/bar*'), equals([p.join('foo', 'bar')])); + }); + }); + + group('double star', () { + test('lists within filenames', () async { + expect( + await list('foo/baz/**'), + unorderedEquals( + [p.join('foo', 'baz', 'qux'), p.join('foo', 'baz', 'bang')])); + }); + + test('lists the empty string', () async { + expect(await list('foo/bar**'), equals([p.join('foo', 'bar')])); + }); + + test('lists recursively', () async { + expect( + await list('foo/**'), + unorderedEquals([ + p.join('foo', 'bar'), + p.join('foo', 'baz'), + p.join('foo', 'baz', 'qux'), + p.join('foo', 'baz', 'bang') + ])); + }); + + test('combines with literals', () async { + expect( + await list('foo/ba**'), + unorderedEquals([ + p.join('foo', 'bar'), + p.join('foo', 'baz'), + p.join('foo', 'baz', 'qux'), + p.join('foo', 'baz', 'bang') + ])); + }); + + test('lists recursively in the middle of a glob', () async { + await d.dir('deep', [ + d.dir('a', [ + d.dir('b', [ + d.dir('c', [d.file('d'), d.file('long-file')]), + d.dir('long-dir', [d.file('x')]) + ]) + ]) + ]).create(); + + expect( + await list('deep/**/?/?'), + unorderedEquals([ + p.join('deep', 'a', 'b', 'c'), + p.join('deep', 'a', 'b', 'c', 'd') + ])); + }); + }); + + group('any char', () { + test('matches a character', () async { + expect(await list('foo/ba?'), + unorderedEquals([p.join('foo', 'bar'), p.join('foo', 'baz')])); + }); + + test("doesn't match a separator", () async { + expect(await list('foo?bar'), isEmpty); + }); + }); + + group('range', () { + test('matches a range of characters', () async { + expect(await list('foo/ba[a-z]'), + unorderedEquals([p.join('foo', 'bar'), p.join('foo', 'baz')])); + }); + + test('matches a specific list of characters', () async { + expect(await list('foo/ba[rz]'), + unorderedEquals([p.join('foo', 'bar'), p.join('foo', 'baz')])); + }); + + test("doesn't match outside its range", () async { + expect( + await list('foo/ba[a-x]'), unorderedEquals([p.join('foo', 'bar')])); + }); + + test("doesn't match outside its specific list", () async { + expect( + await list('foo/ba[rx]'), unorderedEquals([p.join('foo', 'bar')])); + }); + }); + + test("the same file shouldn't be non-recursively listed multiple times", + () async { + await d.dir('multi', [ + d.dir('start-end', [d.file('file')]) + ]).create(); + + expect(await list('multi/{start-*/f*,*-end/*e}'), + equals([p.join('multi', 'start-end', 'file')])); + }); + + test("the same file shouldn't be recursively listed multiple times", + () async { + await d.dir('multi', [ + d.dir('a', [ + d.dir('b', [ + d.file('file'), + d.dir('c', [d.file('file')]) + ]), + d.dir('x', [ + d.dir('y', [d.file('file')]) + ]) + ]) + ]).create(); + + expect( + await list('multi/{*/*/*/file,a/**/file}'), + unorderedEquals([ + p.join('multi', 'a', 'b', 'file'), + p.join('multi', 'a', 'b', 'c', 'file'), + p.join('multi', 'a', 'x', 'y', 'file') + ])); + }); + + group('with symlinks', () { + setUp(() async { + await Link(p.join(d.sandbox, 'dir', 'link')) + .create(p.join(d.sandbox, 'foo', 'baz'), recursive: true); + }); + + test('follows symlinks by default', () async { + expect( + await list('dir/**'), + unorderedEquals([ + p.join('dir', 'link'), + p.join('dir', 'link', 'bang'), + p.join('dir', 'link', 'qux') + ])); + }); + + test("doesn't follow symlinks with followLinks: false", () async { + expect(await list('dir/**', followLinks: false), + equals([p.join('dir', 'link')])); + }); + + test("shouldn't crash on broken symlinks", () async { + await Directory(p.join(d.sandbox, 'foo')).delete(recursive: true); + + expect(await list('dir/**'), equals([p.join('dir', 'link')])); + }); + }); + + test('always lists recursively with recursive: true', () async { + expect( + await list('foo', recursive: true), + unorderedEquals([ + 'foo', + p.join('foo', 'bar'), + p.join('foo', 'baz'), + p.join('foo', 'baz', 'qux'), + p.join('foo', 'baz', 'bang') + ])); + }); + + test('lists an absolute glob', () async { + var pattern = + separatorToForwardSlash(p.absolute(p.join(d.sandbox, 'foo/baz/**'))); + + var result = await list(pattern); + + expect( + result, + unorderedEquals( + [p.join('foo', 'baz', 'bang'), p.join('foo', 'baz', 'qux')])); + }); + + // Regression test for #4. + test('lists an absolute case-insensitive glob', () async { + var pattern = + separatorToForwardSlash(p.absolute(p.join(d.sandbox, 'foo/Baz/**'))); + + expect( + await list(pattern, caseSensitive: false), + unorderedEquals( + [p.join('foo', 'baz', 'bang'), p.join('foo', 'baz', 'qux')])); + }); + + test('lists a subdirectory that sometimes exists', () async { + await d.dir('top', [ + d.dir('dir1', [ + d.dir('subdir', [d.file('file')]) + ]), + d.dir('dir2', []) + ]).create(); + + expect(await list('top/*/subdir/**'), + equals([p.join('top', 'dir1', 'subdir', 'file')])); + }); + + group('when case-insensitive', () { + test('lists literals case-insensitively', () async { + expect(await list('foo/baz/qux', caseSensitive: false), + equals([p.join('foo', 'baz', 'qux')])); + expect(await list('foo/BAZ/qux', caseSensitive: false), + equals([p.join('foo', 'baz', 'qux')])); + }); + + test('lists ranges case-insensitively', () async { + expect(await list('foo/[bx][a-z]z/qux', caseSensitive: false), + equals([p.join('foo', 'baz', 'qux')])); + expect(await list('foo/[BX][A-Z]z/qux', caseSensitive: false), + equals([p.join('foo', 'baz', 'qux')])); + }); + + test('options preserve case-insensitivity', () async { + expect(await list('foo/{bar,baz}/qux', caseSensitive: false), + equals([p.join('foo', 'baz', 'qux')])); + expect(await list('foo/{BAR,BAZ}/qux', caseSensitive: false), + equals([p.join('foo', 'baz', 'qux')])); + }); + }); + }); +} + +typedef ListFn = FutureOr<List<String>> Function(String glob, + {bool recursive, bool followLinks, bool? caseSensitive}); + +/// Runs [callback] in two groups with two values of [ListFn]: one that uses +/// `Glob.list`, one that uses `Glob.listSync`. +void syncAndAsync(FutureOr<void> Function(ListFn) callback) { + group('async', () { + callback((pattern, {recursive = false, followLinks = true, caseSensitive}) { + var glob = + Glob(pattern, recursive: recursive, caseSensitive: caseSensitive); + + return glob + .list(root: d.sandbox, followLinks: followLinks) + .map((entity) => p.relative(entity.path, from: d.sandbox)) + .toList(); + }); + }); + + group('sync', () { + callback((pattern, {recursive = false, followLinks = true, caseSensitive}) { + var glob = + Glob(pattern, recursive: recursive, caseSensitive: caseSensitive); + + return glob + .listSync(root: d.sandbox, followLinks: followLinks) + .map((entity) => p.relative(entity.path, from: d.sandbox)) + .toList(); + }); + }); +}
diff --git a/pkgs/glob/test/match_test.dart b/pkgs/glob/test/match_test.dart new file mode 100644 index 0000000..925d41f --- /dev/null +++ b/pkgs/glob/test/match_test.dart
@@ -0,0 +1,341 @@ +// Copyright (c) 2014, 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 'package:glob/glob.dart'; +import 'package:glob/src/utils.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +const _rawAsciiWithoutSlash = "\t\n\r !\"#\$%&'()*+`-.0123456789:;<=>?@ABCDEF" + 'GHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~'; + +// URL-encode the path for a URL context. +final asciiWithoutSlash = p.style == p.Style.url + ? Uri.encodeFull(_rawAsciiWithoutSlash) + : _rawAsciiWithoutSlash; + +void main() { + test('literals match exactly', () { + expect('foo', contains(Glob('foo'))); + expect('foo/bar', contains(Glob('foo/bar'))); + expect('foo*', contains(Glob(r'foo\*'))); + }); + + test('backslashes match nothing on Windows', () { + expect(r'foo\bar', isNot(contains(Glob(r'foo\\bar', context: p.windows)))); + }); + + group('star', () { + test('matches non-separator characters', () { + var glob = Glob('*'); + expect(asciiWithoutSlash, contains(glob)); + }); + + test('matches the empty string', () { + expect('foo', contains(Glob('foo*'))); + expect('', contains(Glob('*'))); + }); + + test("doesn't match separators", () { + var glob = Glob('*'); + expect('foo/bar', isNot(contains(glob))); + }); + }); + + group('double star', () { + test('matches non-separator characters', () { + var glob = Glob('**'); + expect(asciiWithoutSlash, contains(glob)); + }); + + test('matches the empty string', () { + var glob = Glob('foo**'); + expect('foo', contains(glob)); + }); + + test('matches any level of nesting', () { + var glob = Glob('**'); + expect('a', contains(glob)); + expect('a/b/c/d/e/f', contains(glob)); + }); + + test("doesn't match unresolved dot dots", () { + expect('../foo/bar', isNot(contains(Glob('**')))); + }); + + test('matches entities containing dot dots', () { + expect('..foo/bar', contains(Glob('**'))); + expect('foo../bar', contains(Glob('**'))); + expect('foo/..bar', contains(Glob('**'))); + expect('foo/bar..', contains(Glob('**'))); + }); + }); + + group('any char', () { + test('matches any non-separator character', () { + var glob = Glob('foo?'); + for (var char in _rawAsciiWithoutSlash.split('')) { + if (p.style == p.Style.url) char = Uri.encodeFull(char); + expect('foo$char', contains(glob)); + } + }); + + test("doesn't match a separator", () { + expect('foo/bar', isNot(contains(Glob('foo?bar')))); + }); + }); + + group('range', () { + test('can match individual characters', () { + var glob = Glob('foo[a<.*]'); + expect('fooa', contains(glob)); + expect('foo<', contains(glob)); + expect('foo.', contains(glob)); + expect('foo*', contains(glob)); + expect('foob', isNot(contains(glob))); + expect('foo>', isNot(contains(glob))); + }); + + test('can match a range of characters', () { + var glob = Glob('foo[a-z]'); + expect('fooa', contains(glob)); + expect('foon', contains(glob)); + expect('fooz', contains(glob)); + expect('foo`', isNot(contains(glob))); + expect('foo{', isNot(contains(glob))); + }); + + test('can match multiple ranges of characters', () { + var glob = Glob('foo[a-zA-Z]'); + expect('fooa', contains(glob)); + expect('foon', contains(glob)); + expect('fooz', contains(glob)); + expect('fooA', contains(glob)); + expect('fooN', contains(glob)); + expect('fooZ', contains(glob)); + expect('foo?', isNot(contains(glob))); + expect('foo{', isNot(contains(glob))); + }); + + test('can match individual characters and ranges of characters', () { + var glob = Glob('foo[a-z_A-Z]'); + expect('fooa', contains(glob)); + expect('foon', contains(glob)); + expect('fooz', contains(glob)); + expect('fooA', contains(glob)); + expect('fooN', contains(glob)); + expect('fooZ', contains(glob)); + expect('foo_', contains(glob)); + expect('foo?', isNot(contains(glob))); + expect('foo{', isNot(contains(glob))); + }); + + test('can be negated', () { + var glob = Glob('foo[^a<.*]'); + expect('fooa', isNot(contains(glob))); + expect('foo<', isNot(contains(glob))); + expect('foo.', isNot(contains(glob))); + expect('foo*', isNot(contains(glob))); + expect('foob', contains(glob)); + expect('foo>', contains(glob)); + }); + + test('never matches separators', () { + // "\t-~" contains "/". + expect('foo/bar', isNot(contains(Glob('foo[\t-~]bar')))); + expect('foo/bar', isNot(contains(Glob('foo[^a]bar')))); + }); + + test('allows dangling -', () { + expect('-', contains(Glob(r'[-]'))); + + var glob = Glob(r'[a-]'); + expect('-', contains(glob)); + expect('a', contains(glob)); + + glob = Glob(r'[-b]'); + expect('-', contains(glob)); + expect('b', contains(glob)); + }); + + test('allows multiple -s', () { + expect('-', contains(Glob(r'[--]'))); + expect('-', contains(Glob(r'[---]'))); + + var glob = Glob(r'[--a]'); + expect('-', contains(glob)); + expect('a', contains(glob)); + }); + + test('allows negated /', () { + expect('foo-bar', contains(Glob('foo[^/]bar'))); + }); + + test("doesn't choke on RegExp-active characters", () { + var glob = Glob(r'foo[\]].*'); + expect('foobar', isNot(contains(glob))); + expect('foo].*', contains(glob)); + }); + }); + + group('options', () { + test('match if any of the options match', () { + var glob = Glob('foo/{bar,baz,bang}'); + expect('foo/bar', contains(glob)); + expect('foo/baz', contains(glob)); + expect('foo/bang', contains(glob)); + expect('foo/qux', isNot(contains(glob))); + }); + + test('can contain nested operators', () { + var glob = Glob('foo/{ba?,*az,ban{g,f}}'); + expect('foo/bar', contains(glob)); + expect('foo/baz', contains(glob)); + expect('foo/bang', contains(glob)); + expect('foo/qux', isNot(contains(glob))); + }); + + test('can conditionally match separators', () { + var glob = Glob('foo/{bar,baz/bang}'); + expect('foo/bar', contains(glob)); + expect('foo/baz/bang', contains(glob)); + expect('foo/baz', isNot(contains(glob))); + expect('foo/bar/bang', isNot(contains(glob))); + }); + }); + + group('normalization', () { + test('extra slashes are ignored', () { + expect('foo//bar', contains(Glob('foo/bar'))); + expect('foo/', contains(Glob('*'))); + }); + + test('dot directories are ignored', () { + expect('foo/./bar', contains(Glob('foo/bar'))); + expect('foo/.', contains(Glob('foo'))); + }); + + test('dot dot directories are resolved', () { + expect('foo/../bar', contains(Glob('bar'))); + expect('../foo/bar', contains(Glob('../foo/bar'))); + expect('foo/../../bar', contains(Glob('../bar'))); + }); + + test('Windows separators are converted in a Windows context', () { + expect(r'foo\bar', contains(Glob('foo/bar', context: p.windows))); + expect(r'foo\bar/baz', contains(Glob('foo/bar/baz', context: p.windows))); + }); + }); + + test('an absolute path can be matched by a relative glob', () { + var path = p.absolute('foo/bar'); + expect(path, contains(Glob('foo/bar'))); + }); + + test('a relative path can be matched by an absolute glob', () { + var pattern = separatorToForwardSlash(p.absolute('foo/bar')); + expect('foo/bar', contains(Glob(pattern))); + }, testOn: 'vm'); + + group('with recursive: true', () { + var glob = Glob('foo/bar', recursive: true); + + test('still matches basic files', () { + expect('foo/bar', contains(glob)); + }); + + test('matches subfiles', () { + expect('foo/bar/baz', contains(glob)); + expect('foo/bar/baz/bang', contains(glob)); + }); + + test("doesn't match suffixes", () { + expect('foo/barbaz', isNot(contains(glob))); + expect('foo/barbaz/bang', isNot(contains(glob))); + }); + }); + + test('absolute POSIX paths', () { + expect('/foo/bar', contains(Glob('/foo/bar', context: p.posix))); + expect('/foo/bar', isNot(contains(Glob('**', context: p.posix)))); + expect('/foo/bar', contains(Glob('/**', context: p.posix))); + }); + + test('absolute Windows paths', () { + expect(r'C:\foo\bar', contains(Glob('C:/foo/bar', context: p.windows))); + expect(r'C:\foo\bar', isNot(contains(Glob('**', context: p.windows)))); + expect(r'C:\foo\bar', contains(Glob('C:/**', context: p.windows))); + + expect( + r'\\foo\bar\baz', contains(Glob('//foo/bar/baz', context: p.windows))); + expect(r'\\foo\bar\baz', isNot(contains(Glob('**', context: p.windows)))); + expect(r'\\foo\bar\baz', contains(Glob('//**', context: p.windows))); + expect(r'\\foo\bar\baz', contains(Glob('//foo/**', context: p.windows))); + }); + + test('absolute URL paths', () { + expect(r'http://foo.com/bar', + contains(Glob('http://foo.com/bar', context: p.url))); + expect(r'http://foo.com/bar', isNot(contains(Glob('**', context: p.url)))); + expect(r'http://foo.com/bar', contains(Glob('http://**', context: p.url))); + expect(r'http://foo.com/bar', + contains(Glob('http://foo.com/**', context: p.url))); + + expect('/foo/bar', contains(Glob('/foo/bar', context: p.url))); + expect('/foo/bar', isNot(contains(Glob('**', context: p.url)))); + expect('/foo/bar', contains(Glob('/**', context: p.url))); + }); + + group('when case-sensitive', () { + test('literals match case-sensitively', () { + expect('foo', contains(Glob('foo', caseSensitive: true))); + expect('FOO', isNot(contains(Glob('foo', caseSensitive: true)))); + expect('foo', isNot(contains(Glob('FOO', caseSensitive: true)))); + }); + + test('ranges match case-sensitively', () { + expect('foo', contains(Glob('[fx][a-z]o', caseSensitive: true))); + expect('FOO', isNot(contains(Glob('[fx][a-z]o', caseSensitive: true)))); + expect('foo', isNot(contains(Glob('[FX][A-Z]O', caseSensitive: true)))); + }); + + test('sequences preserve case-sensitivity', () { + expect('foo/bar', contains(Glob('foo/bar', caseSensitive: true))); + expect('FOO/BAR', isNot(contains(Glob('foo/bar', caseSensitive: true)))); + expect('foo/bar', isNot(contains(Glob('FOO/BAR', caseSensitive: true)))); + }); + + test('options preserve case-sensitivity', () { + expect('foo', contains(Glob('{foo,bar}', caseSensitive: true))); + expect('FOO', isNot(contains(Glob('{foo,bar}', caseSensitive: true)))); + expect('foo', isNot(contains(Glob('{FOO,BAR}', caseSensitive: true)))); + }); + }); + + group('when case-insensitive', () { + test('literals match case-insensitively', () { + expect('foo', contains(Glob('foo', caseSensitive: false))); + expect('FOO', contains(Glob('foo', caseSensitive: false))); + expect('foo', contains(Glob('FOO', caseSensitive: false))); + }); + + test('ranges match case-insensitively', () { + expect('foo', contains(Glob('[fx][a-z]o', caseSensitive: false))); + expect('FOO', contains(Glob('[fx][a-z]o', caseSensitive: false))); + expect('foo', contains(Glob('[FX][A-Z]O', caseSensitive: false))); + }); + + test('sequences preserve case-insensitivity', () { + expect('foo/bar', contains(Glob('foo/bar', caseSensitive: false))); + expect('FOO/BAR', contains(Glob('foo/bar', caseSensitive: false))); + expect('foo/bar', contains(Glob('FOO/BAR', caseSensitive: false))); + }); + + test('options preserve case-insensitivity', () { + expect('foo', contains(Glob('{foo,bar}', caseSensitive: false))); + expect('FOO', contains(Glob('{foo,bar}', caseSensitive: false))); + expect('foo', contains(Glob('{FOO,BAR}', caseSensitive: false))); + }); + }); +}
diff --git a/pkgs/glob/test/parse_test.dart b/pkgs/glob/test/parse_test.dart new file mode 100644 index 0000000..5a075e7 --- /dev/null +++ b/pkgs/glob/test/parse_test.dart
@@ -0,0 +1,96 @@ +// Copyright (c) 2014, 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 'package:glob/glob.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('supports backslash-escaped characters', () { + expect(r'*[]{,}?()', contains(Glob(r'\*\[\]\{\,\}\?\(\)'))); + if (p.style != p.Style.windows) { + expect(r'foo\bar', contains(Glob(r'foo\\bar'))); + } + }); + + test('disallows an empty glob', () { + expect(() => Glob(''), throwsFormatException); + }); + + group('range', () { + test('supports either ^ or ! for negated ranges', () { + var bang = Glob('fo[!a-z]'); + expect('foo', isNot(contains(bang))); + expect('fo2', contains(bang)); + + var caret = Glob('fo[^a-z]'); + expect('foo', isNot(contains(caret))); + expect('fo2', contains(caret)); + }); + + test('supports backslash-escaped characters', () { + var glob = Glob(r'fo[\*\--\]]'); + expect('fo]', contains(glob)); + expect('fo-', contains(glob)); + expect('fo*', contains(glob)); + }); + + test('disallows inverted ranges', () { + expect(() => Glob(r'[z-a]'), throwsFormatException); + }); + + test('disallows empty ranges', () { + expect(() => Glob(r'[]'), throwsFormatException); + }); + + test('disallows unclosed ranges', () { + expect(() => Glob(r'[abc'), throwsFormatException); + expect(() => Glob(r'[-'), throwsFormatException); + }); + + test('disallows dangling ]', () { + expect(() => Glob(r'abc]'), throwsFormatException); + }); + + test('disallows explicit /', () { + expect(() => Glob(r'[/]'), throwsFormatException); + expect(() => Glob(r'[ -/]'), throwsFormatException); + expect(() => Glob(r'[/-~]'), throwsFormatException); + }); + }); + + group('options', () { + test('allows empty branches', () { + var glob = Glob('foo{,bar}'); + expect('foo', contains(glob)); + expect('foobar', contains(glob)); + }); + + test('disallows empty options', () { + expect(() => Glob('{}'), throwsFormatException); + }); + + test('disallows single options', () { + expect(() => Glob('{foo}'), throwsFormatException); + }); + + test('disallows unclosed options', () { + expect(() => Glob('{foo,bar'), throwsFormatException); + expect(() => Glob('{foo,'), throwsFormatException); + }); + + test('disallows dangling }', () { + expect(() => Glob('foo}'), throwsFormatException); + }); + + test('disallows dangling ] in options', () { + expect(() => Glob(r'{abc]}'), throwsFormatException); + }); + }); + + test('disallows unescaped parens', () { + expect(() => Glob('foo(bar'), throwsFormatException); + expect(() => Glob('foo)bar'), throwsFormatException); + }); +}