├── analysis_options.yaml ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test-package.yml ├── pubspec.yaml ├── lib ├── list_local_fs.dart ├── src │ ├── utils.dart │ ├── stream_pool.dart │ ├── parser.dart │ ├── ast.dart │ └── list_tree.dart └── glob.dart ├── LICENSE ├── CHANGELOG.md ├── test ├── parse_test.dart ├── glob_test.dart ├── list_test.dart └── match_test.dart └── README.md /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:dart_flutter_team_lints/analysis_options.yaml 2 | 3 | analyzer: 4 | language: 5 | strict-casts: true 6 | 7 | linter: 8 | rules: 9 | - avoid_unused_constructor_parameters 10 | - cancel_subscriptions 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don’t commit the following directories created by pub. 2 | .buildlog 3 | .dart_tool/ 4 | .pub/ 5 | build/ 6 | packages 7 | .packages 8 | 9 | # Or the files created by dart2js. 10 | *.dart.js 11 | *.js_ 12 | *.js.deps 13 | *.js.map 14 | 15 | # Include when developing application packages. 16 | pubspec.lock 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration file. 2 | version: 2 3 | 4 | updates: 5 | - package-ecosystem: github-actions 6 | directory: / 7 | schedule: 8 | interval: monthly 9 | labels: 10 | - autosubmit 11 | groups: 12 | github-actions: 13 | patterns: 14 | - "*" 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # A CI configuration to auto-publish pub packages. 2 | 3 | name: Publish 4 | 5 | on: 6 | pull_request: 7 | branches: [ master ] 8 | types: [opened, synchronize, reopened, labeled, unlabeled] 9 | push: 10 | tags: [ 'v[0-9]+.[0-9]+.[0-9]+*' ] 11 | 12 | jobs: 13 | publish: 14 | if: ${{ github.repository_owner == 'dart-lang' }} 15 | uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main 16 | with: 17 | sdk: dev 18 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: glob 2 | version: 2.1.3-wip 3 | description: A library to perform Bash-style file and directory globbing. 4 | repository: https://github.com/dart-lang/glob 5 | 6 | environment: 7 | sdk: ^3.3.0 8 | 9 | dependencies: 10 | async: ^2.5.0 11 | collection: ^1.15.0 12 | file: '>=6.1.3 <8.0.0' 13 | path: ^1.8.0 14 | string_scanner: ^1.1.0 15 | 16 | dev_dependencies: 17 | dart_flutter_team_lints: ^3.0.0 18 | test: ^1.17.0 19 | test_descriptor: ^2.0.0 20 | -------------------------------------------------------------------------------- /lib/list_local_fs.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:file/file.dart'; 6 | import 'package:file/local.dart'; 7 | 8 | import 'glob.dart'; 9 | 10 | /// Platform specific extensions for where `dart:io` exists, which use the 11 | /// local file system. 12 | extension ListLocalFileSystem on Glob { 13 | /// Convenience method for [Glob.listFileSystem] which uses the local file 14 | /// system. 15 | Stream list({String? root, bool followLinks = true}) => 16 | listFileSystem(const LocalFileSystem(), 17 | root: root, followLinks: followLinks); 18 | 19 | /// Convenience method for [Glob.listFileSystemSync] which uses the local 20 | /// file system. 21 | List listSync({String? root, bool followLinks = true}) => 22 | listFileSystemSync(const LocalFileSystem(), 23 | root: root, followLinks: followLinks); 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014, the Dart project authors. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following 11 | disclaimer in the documentation and/or other materials provided 12 | with the distribution. 13 | * Neither the name of Google LLC nor the names of its 14 | contributors may be used to endorse or promote products derived 15 | from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /.github/workflows/test-package.yml: -------------------------------------------------------------------------------- 1 | name: Dart CI 2 | 3 | on: 4 | # Run on PRs and pushes to the default branch. 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | branches: [ master ] 9 | schedule: 10 | - cron: "0 0 * * 0" 11 | 12 | permissions: read-all 13 | 14 | env: 15 | PUB_ENVIRONMENT: bot.github 16 | 17 | jobs: 18 | # Check code formatting and static analysis on a single OS (linux) 19 | # against Dart dev. 20 | analyze: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | sdk: [dev] 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 28 | - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 29 | with: 30 | channel: ${{ matrix.sdk }} 31 | - id: install 32 | name: Install dependencies 33 | run: dart pub get 34 | - name: Check formatting 35 | run: dart format --output=none --set-exit-if-changed . 36 | if: always() && steps.install.outcome == 'success' 37 | - name: Analyze code 38 | run: dart analyze --fatal-infos 39 | if: always() && steps.install.outcome == 'success' 40 | 41 | # Run tests on a matrix consisting of two dimensions: 42 | # 1. OS: ubuntu-latest, (macos-latest, windows-latest) 43 | # 2. release channel: dev 44 | test: 45 | needs: analyze 46 | runs-on: ${{ matrix.os }} 47 | strategy: 48 | fail-fast: false 49 | matrix: 50 | # Add macos-latest and/or windows-latest if relevant for this package. 51 | os: [ubuntu-latest] 52 | sdk: [3.3, dev] 53 | steps: 54 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 55 | - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 56 | with: 57 | channel: ${{ matrix.sdk }} 58 | - id: install 59 | name: Install dependencies 60 | run: dart pub get 61 | - name: Run VM tests 62 | run: dart test --platform vm 63 | if: always() && steps.install.outcome == 'success' 64 | - name: Run Chrome tests 65 | run: dart test --platform chrome 66 | if: always() && steps.install.outcome == 'success' 67 | - name: Run Node tests 68 | run: dart test --platform node 69 | if: always() && steps.install.outcome == 'success' 70 | - name: Run Chrome tests - wasm 71 | run: dart test --platform chrome --compiler dart2wasm 72 | if: always() && steps.install.outcome == 'success' && matrix.sdk == 'dev' 73 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:path/path.dart' as p; 6 | 7 | /// A range from [min] to [max], inclusive. 8 | class Range { 9 | /// The minimum value included by the range. 10 | final int min; 11 | 12 | /// The maximum value included by the range. 13 | final int max; 14 | 15 | /// Whether this range covers only a single number. 16 | bool get isSingleton => min == max; 17 | 18 | Range(this.min, this.max); 19 | 20 | /// Returns a range that covers only [value]. 21 | Range.singleton(int value) : this(value, value); 22 | 23 | /// Whether `this` contains [value]. 24 | bool contains(int value) => value >= min && value <= max; 25 | 26 | @override 27 | bool operator ==(Object other) => 28 | other is Range && other.min == min && other.max == max; 29 | 30 | @override 31 | int get hashCode => 3 * min + 7 * max; 32 | } 33 | 34 | /// An implementation of [Match] constructed by `Glob`s. 35 | class GlobMatch implements Match { 36 | @override 37 | final String input; 38 | @override 39 | final Pattern pattern; 40 | @override 41 | final int start = 0; 42 | 43 | @override 44 | int get end => input.length; 45 | @override 46 | int get groupCount => 0; 47 | 48 | GlobMatch(this.input, this.pattern); 49 | 50 | @override 51 | String operator [](int group) => this.group(group); 52 | 53 | @override 54 | String group(int group) { 55 | if (group != 0) throw RangeError.range(group, 0, 0); 56 | return input; 57 | } 58 | 59 | @override 60 | List groups(List groupIndices) => 61 | groupIndices.map(group).toList(); 62 | } 63 | 64 | final _quote = RegExp(r'[+*?{}|[\]\\().^$-]'); 65 | 66 | /// Returns [contents] with characters that are meaningful in regular 67 | /// expressions backslash-escaped. 68 | String regExpQuote(String contents) => 69 | contents.replaceAllMapped(_quote, (char) => '\\${char[0]}'); 70 | 71 | /// Returns [path] with all its separators replaced with forward slashes. 72 | /// 73 | /// This is useful when converting from Windows paths to globs. 74 | String separatorToForwardSlash(String path) { 75 | if (p.style != p.Style.windows) return path; 76 | return path.replaceAll('\\', '/'); 77 | } 78 | 79 | /// Returns [path] which follows [context] converted to the POSIX format that 80 | /// globs match against. 81 | String toPosixPath(p.Context context, String path) { 82 | if (context.style == p.Style.windows) return path.replaceAll('\\', '/'); 83 | if (context.style == p.Style.url) return Uri.decodeFull(path); 84 | return path; 85 | } 86 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.1.3-wip 2 | 3 | - Require Dart 3.3. 4 | 5 | ## 2.1.2 6 | 7 | - Allow `file` version `7.x`. 8 | - Require Dart 2.19. 9 | 10 | ## 2.1.1 11 | 12 | - Updated the dependency on `package:file` to require at least `6.1.3`. 13 | 14 | ## 2.1.0 15 | 16 | - Return empty results instead of throwing when trying to list a path that does 17 | not exist. 18 | 19 | ## 2.0.2 20 | 21 | - Drop package:pedantic dependency, use package:lints instead. 22 | - Update SDK lower bound to `2.15.0` 23 | 24 | ## 2.0.1 25 | 26 | - Update example in README for new import. 27 | 28 | ## 2.0.0 29 | 30 | - Stable null safety release. 31 | 32 | ### Breaking Change 33 | 34 | The `list*` apis on `Glob` have been renamed to `listFileSystem*` and they now 35 | require a `FileSystem` object from `package:file`. 36 | 37 | There is a new convenience import, `package:glob/list_local_fs.dart` which 38 | provides the old methods as extensions, and automatically passes a 39 | `LocalFileSystem`. 40 | 41 | ## 1.2.1 42 | 43 | - Add an empty list_local_fs.dart to ease upgrade from 1x to 2x 44 | 45 | ## 1.2.0 46 | 47 | - Support running on Node.js. 48 | 49 | ## 1.1.7 50 | 51 | - Set max SDK version to `<3.0.0`, and adjust other dependencies. 52 | 53 | ## 1.1.6 54 | 55 | - Improve support for Dart 2 runtime semantics. 56 | 57 | ## 1.1.5 58 | 59 | - Declare support for `async` 2.0.0. 60 | 61 | - Require Dart 1.23.0. 62 | 63 | ## 1.1.4 64 | 65 | - Throw an exception when listing globs whose initial paths don't exist in 66 | case-insensitive mode. This matches the case-sensitive behavior. 67 | 68 | ## 1.1.3 69 | 70 | - Support `string_scanner` 1.0.0. 71 | 72 | ## 1.1.2 73 | 74 | - Fix all strong mode errors and warnings. 75 | 76 | ## 1.1.1 77 | 78 | - Fix a bug where listing an absolute glob with `caseInsensitive: false` failed. 79 | 80 | ## 1.1.0 81 | 82 | - Add a `caseSensitive` named parameter to `new Glob()` that controls whether 83 | the glob is case-sensitive. This defaults to `false` on Windows and `true` 84 | elsewhere. 85 | 86 | Matching case-insensitively on Windows is a behavioral change, but since it 87 | more closely matches the semantics of Windows paths it's considered a bug fix 88 | rather than a breaking change. 89 | 90 | ## 1.0.5 91 | 92 | - Narrow the dependency on `path`. Previously, this allowed versions that didn't 93 | support all the functionality this package needs. 94 | 95 | - Upgrade to the new test runner. 96 | 97 | ## 1.0.4 98 | 99 | - Added overlooked `collection` dependency. 100 | 101 | ## 1.0.3 102 | 103 | - Fix a bug where `Glob.list()` and `Glob.listSync()` would incorrectly throw 104 | exceptions when a directory didn't exist on the filesystem. 105 | 106 | ## 1.0.2 107 | 108 | - Fixed `Glob.list()` on Windows. 109 | 110 | ## 1.0.1 111 | 112 | - Fix several analyzer warnings. 113 | 114 | - Fix the tests on Windows. 115 | -------------------------------------------------------------------------------- /lib/src/stream_pool.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | 7 | /// A pool of streams whose events are unified and emitted through a central 8 | /// stream. 9 | class StreamPool { 10 | /// The stream through which all events from streams in the pool are emitted. 11 | Stream get stream => _controller.stream; 12 | final StreamController _controller; 13 | 14 | /// Subscriptions to the streams that make up the pool. 15 | final _subscriptions = , StreamSubscription>{}; 16 | 17 | /// Whether this pool should be closed when it becomes empty. 18 | bool _closeWhenEmpty = false; 19 | 20 | /// Creates a new stream pool that only supports a single subscriber. 21 | /// 22 | /// Any events from broadcast streams in the pool will be buffered until a 23 | /// listener is subscribed. 24 | StreamPool() 25 | // Create the controller as sync so that any sync input streams will be 26 | // forwarded synchronously. Async input streams will have their asynchrony 27 | // preserved, since _controller.add will be called asynchronously. 28 | : _controller = StreamController(sync: true); 29 | 30 | /// Creates a new stream pool where [stream] can be listened to more than 31 | /// once. 32 | /// 33 | /// Any events from buffered streams in the pool will be emitted immediately, 34 | /// regardless of whether [stream] has any subscribers. 35 | StreamPool.broadcast() 36 | // Create the controller as sync so that any sync input streams will be 37 | // forwarded synchronously. Async input streams will have their asynchrony 38 | // preserved, since _controller.add will be called asynchronously. 39 | : _controller = StreamController.broadcast(sync: true); 40 | 41 | /// Adds [stream] as a member of this pool. 42 | /// 43 | /// Any events from [stream] will be emitted through [this.stream]. If 44 | /// [stream] is sync, they'll be emitted synchronously; if [stream] is async, 45 | /// they'll be emitted asynchronously. 46 | void add(Stream stream) { 47 | if (_subscriptions.containsKey(stream)) return; 48 | _subscriptions[stream] = stream.listen(_controller.add, 49 | onError: _controller.addError, onDone: () => remove(stream)); 50 | } 51 | 52 | /// Removes [stream] as a member of this pool. 53 | void remove(Stream stream) { 54 | var subscription = _subscriptions.remove(stream); 55 | if (subscription != null) subscription.cancel(); 56 | if (_closeWhenEmpty && _subscriptions.isEmpty) close(); 57 | } 58 | 59 | /// Removes all streams from this pool and closes [stream]. 60 | void close() { 61 | for (var subscription in _subscriptions.values) { 62 | subscription.cancel(); 63 | } 64 | _subscriptions.clear(); 65 | _controller.close(); 66 | } 67 | 68 | /// The next time this pool becomes empty, close it. 69 | void closeWhenEmpty() { 70 | if (_subscriptions.isEmpty) close(); 71 | _closeWhenEmpty = true; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/parse_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:glob/glob.dart'; 6 | import 'package:path/path.dart' as p; 7 | import 'package:test/test.dart'; 8 | 9 | void main() { 10 | test('supports backslash-escaped characters', () { 11 | expect(r'*[]{,}?()', contains(Glob(r'\*\[\]\{\,\}\?\(\)'))); 12 | if (p.style != p.Style.windows) { 13 | expect(r'foo\bar', contains(Glob(r'foo\\bar'))); 14 | } 15 | }); 16 | 17 | test('disallows an empty glob', () { 18 | expect(() => Glob(''), throwsFormatException); 19 | }); 20 | 21 | group('range', () { 22 | test('supports either ^ or ! for negated ranges', () { 23 | var bang = Glob('fo[!a-z]'); 24 | expect('foo', isNot(contains(bang))); 25 | expect('fo2', contains(bang)); 26 | 27 | var caret = Glob('fo[^a-z]'); 28 | expect('foo', isNot(contains(caret))); 29 | expect('fo2', contains(caret)); 30 | }); 31 | 32 | test('supports backslash-escaped characters', () { 33 | var glob = Glob(r'fo[\*\--\]]'); 34 | expect('fo]', contains(glob)); 35 | expect('fo-', contains(glob)); 36 | expect('fo*', contains(glob)); 37 | }); 38 | 39 | test('disallows inverted ranges', () { 40 | expect(() => Glob(r'[z-a]'), throwsFormatException); 41 | }); 42 | 43 | test('disallows empty ranges', () { 44 | expect(() => Glob(r'[]'), throwsFormatException); 45 | }); 46 | 47 | test('disallows unclosed ranges', () { 48 | expect(() => Glob(r'[abc'), throwsFormatException); 49 | expect(() => Glob(r'[-'), throwsFormatException); 50 | }); 51 | 52 | test('disallows dangling ]', () { 53 | expect(() => Glob(r'abc]'), throwsFormatException); 54 | }); 55 | 56 | test('disallows explicit /', () { 57 | expect(() => Glob(r'[/]'), throwsFormatException); 58 | expect(() => Glob(r'[ -/]'), throwsFormatException); 59 | expect(() => Glob(r'[/-~]'), throwsFormatException); 60 | }); 61 | }); 62 | 63 | group('options', () { 64 | test('allows empty branches', () { 65 | var glob = Glob('foo{,bar}'); 66 | expect('foo', contains(glob)); 67 | expect('foobar', contains(glob)); 68 | }); 69 | 70 | test('disallows empty options', () { 71 | expect(() => Glob('{}'), throwsFormatException); 72 | }); 73 | 74 | test('disallows single options', () { 75 | expect(() => Glob('{foo}'), throwsFormatException); 76 | }); 77 | 78 | test('disallows unclosed options', () { 79 | expect(() => Glob('{foo,bar'), throwsFormatException); 80 | expect(() => Glob('{foo,'), throwsFormatException); 81 | }); 82 | 83 | test('disallows dangling }', () { 84 | expect(() => Glob('foo}'), throwsFormatException); 85 | }); 86 | 87 | test('disallows dangling ] in options', () { 88 | expect(() => Glob(r'{abc]}'), throwsFormatException); 89 | }); 90 | }); 91 | 92 | test('disallows unescaped parens', () { 93 | expect(() => Glob('foo(bar'), throwsFormatException); 94 | expect(() => Glob('foo)bar'), throwsFormatException); 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /test/glob_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:glob/glob.dart'; 6 | import 'package:path/path.dart' as p; 7 | import 'package:test/test.dart'; 8 | 9 | void main() { 10 | group('Glob.quote()', () { 11 | test('quotes all active characters', () { 12 | expect(Glob.quote('*{[?\\}],-'), equals(r'\*\{\[\?\\\}\]\,\-')); 13 | }); 14 | 15 | test("doesn't quote inactive characters", () { 16 | expect(Glob.quote('abc~`_+='), equals('abc~`_+=')); 17 | }); 18 | }); 19 | 20 | group('Glob.matches()', () { 21 | test('returns whether the path matches the glob', () { 22 | var glob = Glob('foo*'); 23 | expect(glob.matches('foobar'), isTrue); 24 | expect(glob.matches('baz'), isFalse); 25 | }); 26 | 27 | test('only matches the entire path', () { 28 | var glob = Glob('foo'); 29 | expect(glob.matches('foo/bar'), isFalse); 30 | expect(glob.matches('bar/foo'), isFalse); 31 | }); 32 | }); 33 | 34 | group('Glob.matchAsPrefix()', () { 35 | test('returns a match if the path matches the glob', () { 36 | var glob = Glob('foo*'); 37 | expect(glob.matchAsPrefix('foobar'), isA()); 38 | expect(glob.matchAsPrefix('baz'), isNull); 39 | }); 40 | 41 | test('returns null for start > 0', () { 42 | var glob = Glob('*'); 43 | expect(glob.matchAsPrefix('foobar', 1), isNull); 44 | }); 45 | }); 46 | 47 | group('Glob.allMatches()', () { 48 | test('returns a single match if the path matches the glob', () { 49 | var matches = Glob('foo*').allMatches('foobar'); 50 | expect(matches, hasLength(1)); 51 | expect(matches.first, isA()); 52 | }); 53 | 54 | test("returns an empty list if the path doesn't match the glob", () { 55 | expect(Glob('foo*').allMatches('baz'), isEmpty); 56 | }); 57 | 58 | test('returns no matches for start > 0', () { 59 | var glob = Glob('*'); 60 | expect(glob.allMatches('foobar', 1), isEmpty); 61 | }); 62 | }); 63 | 64 | group('GlobMatch', () { 65 | var glob = Glob('foo*'); 66 | var match = glob.matchAsPrefix('foobar')!; 67 | 68 | test('returns the string as input', () { 69 | expect(match.input, equals('foobar')); 70 | }); 71 | 72 | test('returns the glob as the pattern', () { 73 | expect(match.pattern, equals(glob)); 74 | }); 75 | 76 | test('returns the span of the string for start and end', () { 77 | expect(match.start, equals(0)); 78 | expect(match.end, equals('foobar'.length)); 79 | }); 80 | 81 | test('has a single group that contains the whole string', () { 82 | expect(match.groupCount, equals(0)); 83 | expect(match[0], equals('foobar')); 84 | expect(match.group(0), equals('foobar')); 85 | expect(match.groups([0]), equals(['foobar'])); 86 | }); 87 | 88 | test('throws a range error for an invalid group', () { 89 | expect(() => match[1], throwsRangeError); 90 | expect(() => match[-1], throwsRangeError); 91 | expect(() => match.group(1), throwsRangeError); 92 | expect(() => match.groups([1]), throwsRangeError); 93 | }); 94 | }); 95 | 96 | test('globs are case-sensitive by default for Posix and URL contexts', () { 97 | expect('foo', contains(Glob('foo', context: p.posix))); 98 | expect('FOO', isNot(contains(Glob('foo', context: p.posix)))); 99 | expect('foo', isNot(contains(Glob('FOO', context: p.posix)))); 100 | 101 | expect('foo', contains(Glob('foo', context: p.url))); 102 | expect('FOO', isNot(contains(Glob('foo', context: p.url)))); 103 | expect('foo', isNot(contains(Glob('FOO', context: p.url)))); 104 | }); 105 | 106 | test('globs are case-insensitive by default for Windows contexts', () { 107 | expect('foo', contains(Glob('foo', context: p.windows))); 108 | expect('FOO', contains(Glob('foo', context: p.windows))); 109 | expect('foo', contains(Glob('FOO', context: p.windows))); 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This repo has moved to https://github.com/dart-lang/tools/tree/main/pkgs/glob 3 | 4 | [![Dart CI](https://github.com/dart-lang/glob/actions/workflows/test-package.yml/badge.svg)](https://github.com/dart-lang/glob/actions/workflows/test-package.yml) 5 | [![pub package](https://img.shields.io/pub/v/glob.svg)](https://pub.dev/packages/glob) 6 | [![package publisher](https://img.shields.io/pub/publisher/glob.svg)](https://pub.dev/packages/glob/publisher) 7 | 8 | `glob` is a file and directory globbing library that supports both checking 9 | whether a path matches a glob and listing all entities that match a glob. 10 | 11 | A "glob" is a pattern designed specifically to match files and directories. Most 12 | shells support globs natively. 13 | 14 | ## Usage 15 | 16 | To construct a glob, just use `Glob()`. As with `RegExp`s, it's a good idea to 17 | keep around a glob if you'll be using it more than once so that it doesn't have 18 | to be compiled over and over. You can check whether a path matches the glob 19 | using `Glob.matches()`: 20 | 21 | ```dart 22 | import 'package:glob/glob.dart'; 23 | 24 | final dartFile = Glob("**.dart"); 25 | 26 | // Print all command-line arguments that are Dart files. 27 | void main(List arguments) { 28 | for (var argument in arguments) { 29 | if (dartFile.matches(argument)) print(argument); 30 | } 31 | } 32 | ``` 33 | 34 | You can also list all files that match a glob using `Glob.list()` or 35 | `Glob.listSync()`: 36 | 37 | ```dart 38 | import 'package:glob/glob.dart'; 39 | import 'package:glob/list_local_fs.dart'; 40 | 41 | final dartFile = Glob("**.dart"); 42 | 43 | // Recursively list all Dart files in the current directory. 44 | void main(List arguments) { 45 | for (var entity in dartFile.listSync()) { 46 | print(entity.path); 47 | } 48 | } 49 | ``` 50 | 51 | ## Syntax 52 | 53 | The glob syntax hews closely to the widely-known Bash glob syntax, with a few 54 | exceptions that are outlined below. 55 | 56 | In order to be as cross-platform and as close to the Bash syntax as possible, 57 | all globs use POSIX path syntax, including using `/` as a directory separator 58 | regardless of which platform they're on. This is true even for Windows roots; 59 | for example, a glob matching all files in the C drive would be `C:/*`. 60 | 61 | Globs are case-sensitive by default on Posix systems and browsers, and 62 | case-insensitive by default on Windows. 63 | 64 | ### Match any characters in a filename: `*` 65 | 66 | The `*` character matches zero or more of any character other than `/`. This 67 | means that it can be used to match all files in a given directory that match a 68 | pattern without also matching files in a subdirectory. For example, `lib/*.dart` 69 | will match `lib/glob.dart` but not `lib/src/utils.dart`. 70 | 71 | ### Match any characters across directories: `**` 72 | 73 | `**` is like `*`, but matches `/` as well. It's useful for matching files or 74 | listing directories recursively. For example, `lib/**.dart` will match both 75 | `lib/glob.dart` and `lib/src/utils.dart`. 76 | 77 | If `**` appears at the beginning of a glob, it won't match absolute paths or 78 | paths beginning with `../`. For example, `**.dart` won't match `/foo.dart`, 79 | although `/**.dart` will. This is to ensure that listing a bunch of paths and 80 | checking whether they match a glob produces the same results as listing that 81 | glob. In the previous example, `/foo.dart` wouldn't be listed for `**.dart`, so 82 | it shouldn't be matched by it either. 83 | 84 | This is an extension to Bash glob syntax that's widely supported by other glob 85 | implementations. 86 | 87 | ### Match any single character: `?` 88 | 89 | The `?` character matches a single character other than `/`. Unlike `*`, it 90 | won't match any more or fewer than one character. For example, `test?.dart` will 91 | match `test1.dart` but not `test10.dart` or `test.dart`. 92 | 93 | ### Match a range of characters: `[...]` 94 | 95 | The `[...]` construction matches one of several characters. It can contain 96 | individual characters, such as `[abc]`, in which case it will match any of those 97 | characters; it can contain ranges, such as `[a-zA-Z]`, in which case it will 98 | match any characters that fall within the range; or it can contain a mix of 99 | both. It will only ever match a single character. For example, 100 | `test[a-zA-Z_].dart` will match `testx.dart`, `testA.dart`, and `test_.dart`, 101 | but not `test-.dart`. 102 | 103 | If it starts with `^` or `!`, the construction will instead match all characters 104 | _not_ mentioned. For example, `test[^a-z].dart` will match `test1.dart` but not 105 | `testa.dart`. 106 | 107 | This construction never matches `/`. 108 | 109 | ### Match one of several possibilities: `{...,...}` 110 | 111 | The `{...,...}` construction matches one of several options, each of which is a 112 | glob itself. For example, `lib/{*.dart,src/*}` matches `lib/glob.dart` and 113 | `lib/src/data.txt`. It can contain any number of options greater than one, and 114 | can even contain nested options. 115 | 116 | This is an extension to Bash glob syntax, although it is supported by other 117 | layers of Bash and is often used in conjunction with globs. 118 | 119 | ### Escaping a character: `\` 120 | 121 | The `\` character can be used in any context to escape a character that would 122 | otherwise be semantically meaningful. For example, `\*.dart` matches `*.dart` 123 | but not `test.dart`. 124 | 125 | ### Syntax errors 126 | 127 | Because they're used as part of the shell, almost all strings are valid Bash 128 | globs. This implementation is more picky, and performs some validation to ensure 129 | that globs are meaningful. For instance, unclosed `{` and `[` are disallowed. 130 | 131 | ### Reserved syntax: `(...)` 132 | 133 | Parentheses are reserved in case this package adds support for Bash extended 134 | globbing in the future. For the time being, using them will throw an error 135 | unless they're escaped. 136 | -------------------------------------------------------------------------------- /lib/src/parser.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:path/path.dart' as p; 6 | import 'package:string_scanner/string_scanner.dart'; 7 | 8 | import 'ast.dart'; 9 | import 'utils.dart'; 10 | 11 | const _hyphen = 0x2D; 12 | const _slash = 0x2F; 13 | 14 | /// A parser for globs. 15 | class Parser { 16 | /// The scanner used to scan the source. 17 | final StringScanner _scanner; 18 | 19 | /// The path context for the glob. 20 | final p.Context _context; 21 | 22 | /// Whether this glob is case-sensitive. 23 | final bool _caseSensitive; 24 | 25 | Parser(String component, this._context, {bool caseSensitive = true}) 26 | : _scanner = StringScanner(component), 27 | _caseSensitive = caseSensitive; 28 | 29 | /// Parses an entire glob. 30 | SequenceNode parse() => _parseSequence(); 31 | 32 | /// Parses a [SequenceNode]. 33 | /// 34 | /// If [inOptions] is true, this is parsing within an [OptionsNode]. 35 | SequenceNode _parseSequence({bool inOptions = false}) { 36 | var nodes = []; 37 | 38 | if (_scanner.isDone) { 39 | _scanner.error('expected a glob.', position: 0, length: 0); 40 | } 41 | 42 | while (!_scanner.isDone) { 43 | if (inOptions && (_scanner.matches(',') || _scanner.matches('}'))) break; 44 | nodes.add(_parseNode(inOptions: inOptions)); 45 | } 46 | 47 | return SequenceNode(nodes, caseSensitive: _caseSensitive); 48 | } 49 | 50 | /// Parses an [AstNode]. 51 | /// 52 | /// If [inOptions] is true, this is parsing within an [OptionsNode]. 53 | AstNode _parseNode({bool inOptions = false}) { 54 | var star = _parseStar(); 55 | if (star != null) return star; 56 | 57 | var anyChar = _parseAnyChar(); 58 | if (anyChar != null) return anyChar; 59 | 60 | var range = _parseRange(); 61 | if (range != null) return range; 62 | 63 | var options = _parseOptions(); 64 | if (options != null) return options; 65 | 66 | return _parseLiteral(inOptions: inOptions); 67 | } 68 | 69 | /// Tries to parse a [StarNode] or a [DoubleStarNode]. 70 | /// 71 | /// Returns `null` if there's not one to parse. 72 | AstNode? _parseStar() { 73 | if (!_scanner.scan('*')) return null; 74 | return _scanner.scan('*') 75 | ? DoubleStarNode(_context, caseSensitive: _caseSensitive) 76 | : StarNode(caseSensitive: _caseSensitive); 77 | } 78 | 79 | /// Tries to parse an [AnyCharNode]. 80 | /// 81 | /// Returns `null` if there's not one to parse. 82 | AstNode? _parseAnyChar() { 83 | if (!_scanner.scan('?')) return null; 84 | return AnyCharNode(caseSensitive: _caseSensitive); 85 | } 86 | 87 | /// Tries to parse an [RangeNode]. 88 | /// 89 | /// Returns `null` if there's not one to parse. 90 | AstNode? _parseRange() { 91 | if (!_scanner.scan('[')) return null; 92 | if (_scanner.matches(']')) _scanner.error('unexpected "]".'); 93 | var negated = _scanner.scan('!') || _scanner.scan('^'); 94 | 95 | int readRangeChar() { 96 | var char = _scanner.readChar(); 97 | if (negated || char != _slash) return char; 98 | _scanner.error('"/" may not be used in a range.', 99 | position: _scanner.position - 1); 100 | } 101 | 102 | var ranges = []; 103 | while (!_scanner.scan(']')) { 104 | var start = _scanner.position; 105 | // Allow a backslash to escape a character. 106 | _scanner.scan('\\'); 107 | var char = readRangeChar(); 108 | 109 | if (_scanner.scan('-')) { 110 | if (_scanner.matches(']')) { 111 | ranges.add(Range.singleton(char)); 112 | ranges.add(Range.singleton(_hyphen)); 113 | continue; 114 | } 115 | 116 | // Allow a backslash to escape a character. 117 | _scanner.scan('\\'); 118 | 119 | var end = readRangeChar(); 120 | 121 | if (end < char) { 122 | _scanner.error('Range out of order.', 123 | position: start, length: _scanner.position - start); 124 | } 125 | ranges.add(Range(char, end)); 126 | } else { 127 | ranges.add(Range.singleton(char)); 128 | } 129 | } 130 | 131 | return RangeNode(ranges, negated: negated, caseSensitive: _caseSensitive); 132 | } 133 | 134 | /// Tries to parse an [OptionsNode]. 135 | /// 136 | /// Returns `null` if there's not one to parse. 137 | AstNode? _parseOptions() { 138 | if (!_scanner.scan('{')) return null; 139 | if (_scanner.matches('}')) _scanner.error('unexpected "}".'); 140 | 141 | var options = []; 142 | do { 143 | options.add(_parseSequence(inOptions: true)); 144 | } while (_scanner.scan(',')); 145 | 146 | // Don't allow single-option blocks. 147 | if (options.length == 1) _scanner.expect(','); 148 | _scanner.expect('}'); 149 | 150 | return OptionsNode(options, caseSensitive: _caseSensitive); 151 | } 152 | 153 | /// Parses a [LiteralNode]. 154 | AstNode _parseLiteral({bool inOptions = false}) { 155 | // If we're in an options block, we want to stop parsing as soon as we hit a 156 | // comma. Otherwise, commas are fair game for literals. 157 | var regExp = RegExp(inOptions ? r'[^*{[?\\}\],()]*' : r'[^*{[?\\}\]()]*'); 158 | 159 | _scanner.scan(regExp); 160 | var buffer = StringBuffer()..write(_scanner.lastMatch![0]); 161 | 162 | while (_scanner.scan('\\')) { 163 | buffer.writeCharCode(_scanner.readChar()); 164 | _scanner.scan(regExp); 165 | buffer.write(_scanner.lastMatch![0]); 166 | } 167 | 168 | for (var char in const [']', '(', ')']) { 169 | if (_scanner.matches(char)) _scanner.error('unexpected "$char"'); 170 | } 171 | if (!inOptions && _scanner.matches('}')) _scanner.error('unexpected "}"'); 172 | 173 | return LiteralNode(buffer.toString(), 174 | context: _context, caseSensitive: _caseSensitive); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /lib/glob.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:file/file.dart'; 6 | import 'package:file/memory.dart'; 7 | import 'package:path/path.dart' as p; 8 | 9 | import 'src/ast.dart'; 10 | import 'src/list_tree.dart'; 11 | import 'src/parser.dart'; 12 | import 'src/utils.dart'; 13 | 14 | /// Regular expression used to quote globs. 15 | final _quoteRegExp = RegExp(r'[*{[?\\}\],\-()]'); 16 | 17 | /// A glob for matching and listing files and directories. 18 | /// 19 | /// A glob matches an entire string as a path. Although the glob pattern uses 20 | /// POSIX syntax, it can match against POSIX, Windows, or URL paths. The format 21 | /// it expects paths to use is based on the `context` parameter to [Glob.new]; 22 | /// it defaults to the current system's syntax. 23 | /// 24 | /// Paths are normalized before being matched against a glob, so for example the 25 | /// glob `foo/bar` matches the path `foo/./bar`. A relative glob can match an 26 | /// absolute path and vice versa; globs and paths are both interpreted as 27 | /// relative to `context.current`, which defaults to the current working 28 | /// directory. 29 | /// 30 | /// When used as a [Pattern], a glob will return either one or zero matches for 31 | /// a string depending on whether the entire string matches the glob. These 32 | /// matches don't currently have capture groups, although this may change in the 33 | /// future. 34 | class Glob implements Pattern { 35 | /// The pattern used to create this glob. 36 | final String pattern; 37 | 38 | /// The context in which paths matched against this glob are interpreted. 39 | final p.Context context; 40 | 41 | /// If true, a path matches if it matches the glob itself or is recursively 42 | /// contained within a directory that matches. 43 | final bool recursive; 44 | 45 | /// Whether the glob matches paths case-sensitively. 46 | bool get caseSensitive => _ast.caseSensitive; 47 | 48 | /// The parsed AST of the glob. 49 | final AstNode _ast; 50 | 51 | /// The underlying object used to implement [listFileSystem] and 52 | /// [listFileSystemSync]. 53 | /// 54 | /// This should not be read directly outside of [_listTreeForFileSystem]. 55 | ListTree? _listTree; 56 | 57 | /// Keeps track of the previous file system used. If this changes then the 58 | /// [_listTree] must be invalidated. 59 | /// 60 | /// This is handled inside of [_listTreeForFileSystem]. 61 | FileSystem? _previousFileSystem; 62 | 63 | /// Whether [context]'s current directory is absolute. 64 | bool get _contextIsAbsolute => 65 | _contextIsAbsoluteCache ??= context.isAbsolute(context.current); 66 | 67 | bool? _contextIsAbsoluteCache; 68 | 69 | /// Whether [pattern] could match absolute paths. 70 | bool get _patternCanMatchAbsolute => 71 | _patternCanMatchAbsoluteCache ??= _ast.canMatchAbsolute; 72 | 73 | bool? _patternCanMatchAbsoluteCache; 74 | 75 | /// Whether [pattern] could match relative paths. 76 | bool get _patternCanMatchRelative => 77 | _patternCanMatchRelativeCache ??= _ast.canMatchRelative; 78 | 79 | bool? _patternCanMatchRelativeCache; 80 | 81 | /// Returns [contents] with characters that are meaningful in globs 82 | /// backslash-escaped. 83 | static String quote(String contents) => 84 | contents.replaceAllMapped(_quoteRegExp, (match) => '\\${match[0]}'); 85 | 86 | /// Creates a new glob with [pattern]. 87 | /// 88 | /// Paths matched against the glob are interpreted according to [context]. It 89 | /// defaults to the system context. 90 | /// 91 | /// If [recursive] is true, this glob matches and lists not only the files and 92 | /// directories it explicitly matches, but anything beneath those as well. 93 | /// 94 | /// If [caseSensitive] is true, this glob matches and lists only files whose 95 | /// case matches that of the characters in the glob. Otherwise, it matches 96 | /// regardless of case. This defaults to `false` when [context] is Windows and 97 | /// `true` otherwise. 98 | factory Glob(String pattern, 99 | {p.Context? context, bool recursive = false, bool? caseSensitive}) { 100 | context ??= p.context; 101 | caseSensitive ??= context.style == p.Style.windows ? false : true; 102 | if (recursive) pattern += '{,/**}'; 103 | 104 | var parser = Parser(pattern, context, caseSensitive: caseSensitive); 105 | return Glob._(pattern, context, parser.parse(), recursive); 106 | } 107 | 108 | Glob._(this.pattern, this.context, this._ast, this.recursive); 109 | 110 | /// Lists all [FileSystemEntity]s beneath [root] that match the glob in the 111 | /// provided [fileSystem]. 112 | /// 113 | /// This works much like [Directory.list], but it only lists directories that 114 | /// could contain entities that match the glob. It provides no guarantees 115 | /// about the order of the returned entities, although it does guarantee that 116 | /// only one entity with a given path will be returned. 117 | /// 118 | /// [root] defaults to the current working directory. 119 | /// 120 | /// [followLinks] works the same as for [Directory.list]. 121 | Stream listFileSystem(FileSystem fileSystem, 122 | {String? root, bool followLinks = true}) { 123 | if (context.style != p.style) { 124 | throw StateError("Can't list glob \"$this\"; it matches " 125 | '${context.style} paths, but this platform uses ${p.style} paths.'); 126 | } 127 | 128 | return _listTreeForFileSystem(fileSystem) 129 | .list(root: root, followLinks: followLinks); 130 | } 131 | 132 | /// Synchronously lists all [FileSystemEntity]s beneath [root] that match the 133 | /// glob in the provided [fileSystem]. 134 | /// 135 | /// This works much like [Directory.listSync], but it only lists directories 136 | /// that could contain entities that match the glob. It provides no guarantees 137 | /// about the order of the returned entities, although it does guarantee that 138 | /// only one entity with a given path will be returned. 139 | /// 140 | /// [root] defaults to the current working directory. 141 | /// 142 | /// [followLinks] works the same as for [Directory.list]. 143 | List listFileSystemSync(FileSystem fileSystem, 144 | {String? root, bool followLinks = true}) { 145 | if (context.style != p.style) { 146 | throw StateError("Can't list glob \"$this\"; it matches " 147 | '${context.style} paths, but this platform uses ${p.style} paths.'); 148 | } 149 | 150 | return _listTreeForFileSystem(fileSystem) 151 | .listSync(root: root, followLinks: followLinks); 152 | } 153 | 154 | /// Returns whether this glob matches [path]. 155 | bool matches(String path) => matchAsPrefix(path) != null; 156 | 157 | @override 158 | Match? matchAsPrefix(String path, [int start = 0]) { 159 | // Globs are like anchored RegExps in that they only match entire paths, so 160 | // if the match starts anywhere after the first character it can't succeed. 161 | if (start != 0) return null; 162 | 163 | if (_patternCanMatchAbsolute && 164 | (_contextIsAbsolute || context.isAbsolute(path))) { 165 | var absolutePath = context.normalize(context.absolute(path)); 166 | if (_ast.matches(toPosixPath(context, absolutePath))) { 167 | return GlobMatch(path, this); 168 | } 169 | } 170 | 171 | if (_patternCanMatchRelative) { 172 | var relativePath = context.relative(path); 173 | if (_ast.matches(toPosixPath(context, relativePath))) { 174 | return GlobMatch(path, this); 175 | } 176 | } 177 | 178 | return null; 179 | } 180 | 181 | @override 182 | Iterable allMatches(String path, [int start = 0]) { 183 | var match = matchAsPrefix(path, start); 184 | return match == null ? [] : [match]; 185 | } 186 | 187 | @override 188 | String toString() => pattern; 189 | 190 | /// Handles getting a possibly cached [ListTree] for a [fileSystem]. 191 | ListTree _listTreeForFileSystem(FileSystem fileSystem) { 192 | // Don't use cached trees for in memory file systems to avoid memory leaks. 193 | if (fileSystem is MemoryFileSystem) return ListTree(_ast, fileSystem); 194 | 195 | // Throw away our cached `_listTree` if the file system is different. 196 | if (fileSystem != _previousFileSystem) { 197 | _listTree = null; 198 | _previousFileSystem = fileSystem; 199 | } 200 | 201 | return _listTree ??= ListTree(_ast, fileSystem); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /test/list_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | @TestOn('vm') 6 | library; 7 | 8 | import 'dart:async'; 9 | import 'dart:io'; 10 | 11 | import 'package:glob/glob.dart'; 12 | import 'package:glob/list_local_fs.dart'; 13 | import 'package:glob/src/utils.dart'; 14 | import 'package:path/path.dart' as p; 15 | import 'package:test/test.dart'; 16 | import 'package:test_descriptor/test_descriptor.dart' as d; 17 | 18 | void main() { 19 | setUp(() async { 20 | await d.dir('foo', [ 21 | d.file('bar'), 22 | d.dir('baz', [d.file('bang'), d.file('qux')]) 23 | ]).create(); 24 | }); 25 | 26 | group('list()', () { 27 | test("fails if the context doesn't match the system context", () { 28 | expect(Glob('*', context: p.url).list, throwsStateError); 29 | }); 30 | 31 | test('returns empty list for non-existent case-sensitive directories', 32 | () async { 33 | expect(await Glob('non/existent/**', caseSensitive: true).list().toList(), 34 | []); 35 | }); 36 | 37 | test('returns empty list for non-existent case-insensitive directories', 38 | () async { 39 | expect( 40 | await Glob('non/existent/**', caseSensitive: false).list().toList(), 41 | []); 42 | }); 43 | }); 44 | 45 | group('listSync()', () { 46 | test("fails if the context doesn't match the system context", () { 47 | expect(Glob('*', context: p.url).listSync, throwsStateError); 48 | }); 49 | 50 | test('returns empty list for non-existent case-sensitive directories', () { 51 | expect( 52 | Glob('non/existent/**', caseSensitive: true).listSync(), []); 53 | }); 54 | 55 | test('returns empty list for non-existent case-insensitive directories', 56 | () { 57 | expect( 58 | Glob('non/existent/**', caseSensitive: false).listSync(), []); 59 | }); 60 | }); 61 | 62 | group('when case-sensitive', () { 63 | test('lists literals case-sensitively', () { 64 | expect(Glob('foo/BAZ/qux', caseSensitive: true).listSync(), []); 65 | }); 66 | 67 | test('lists ranges case-sensitively', () { 68 | expect(Glob('foo/[BX][A-Z]z/qux', caseSensitive: true).listSync(), 69 | []); 70 | }); 71 | 72 | test('options preserve case-sensitivity', () { 73 | expect( 74 | Glob('foo/{BAZ,ZAP}/qux', caseSensitive: true).listSync(), []); 75 | }); 76 | }); 77 | 78 | syncAndAsync((ListFn list) { 79 | group('literals', () { 80 | test('lists a single literal', () async { 81 | expect( 82 | await list('foo/baz/qux'), equals([p.join('foo', 'baz', 'qux')])); 83 | }); 84 | 85 | test('lists a non-matching literal', () async { 86 | expect(await list('foo/baz/nothing'), isEmpty); 87 | }); 88 | }); 89 | 90 | group('star', () { 91 | test('lists within filenames but not across directories', () async { 92 | expect(await list('foo/b*'), 93 | unorderedEquals([p.join('foo', 'bar'), p.join('foo', 'baz')])); 94 | }); 95 | 96 | test('lists the empy string', () async { 97 | expect(await list('foo/bar*'), equals([p.join('foo', 'bar')])); 98 | }); 99 | }); 100 | 101 | group('double star', () { 102 | test('lists within filenames', () async { 103 | expect( 104 | await list('foo/baz/**'), 105 | unorderedEquals( 106 | [p.join('foo', 'baz', 'qux'), p.join('foo', 'baz', 'bang')])); 107 | }); 108 | 109 | test('lists the empty string', () async { 110 | expect(await list('foo/bar**'), equals([p.join('foo', 'bar')])); 111 | }); 112 | 113 | test('lists recursively', () async { 114 | expect( 115 | await list('foo/**'), 116 | unorderedEquals([ 117 | p.join('foo', 'bar'), 118 | p.join('foo', 'baz'), 119 | p.join('foo', 'baz', 'qux'), 120 | p.join('foo', 'baz', 'bang') 121 | ])); 122 | }); 123 | 124 | test('combines with literals', () async { 125 | expect( 126 | await list('foo/ba**'), 127 | unorderedEquals([ 128 | p.join('foo', 'bar'), 129 | p.join('foo', 'baz'), 130 | p.join('foo', 'baz', 'qux'), 131 | p.join('foo', 'baz', 'bang') 132 | ])); 133 | }); 134 | 135 | test('lists recursively in the middle of a glob', () async { 136 | await d.dir('deep', [ 137 | d.dir('a', [ 138 | d.dir('b', [ 139 | d.dir('c', [d.file('d'), d.file('long-file')]), 140 | d.dir('long-dir', [d.file('x')]) 141 | ]) 142 | ]) 143 | ]).create(); 144 | 145 | expect( 146 | await list('deep/**/?/?'), 147 | unorderedEquals([ 148 | p.join('deep', 'a', 'b', 'c'), 149 | p.join('deep', 'a', 'b', 'c', 'd') 150 | ])); 151 | }); 152 | }); 153 | 154 | group('any char', () { 155 | test('matches a character', () async { 156 | expect(await list('foo/ba?'), 157 | unorderedEquals([p.join('foo', 'bar'), p.join('foo', 'baz')])); 158 | }); 159 | 160 | test("doesn't match a separator", () async { 161 | expect(await list('foo?bar'), isEmpty); 162 | }); 163 | }); 164 | 165 | group('range', () { 166 | test('matches a range of characters', () async { 167 | expect(await list('foo/ba[a-z]'), 168 | unorderedEquals([p.join('foo', 'bar'), p.join('foo', 'baz')])); 169 | }); 170 | 171 | test('matches a specific list of characters', () async { 172 | expect(await list('foo/ba[rz]'), 173 | unorderedEquals([p.join('foo', 'bar'), p.join('foo', 'baz')])); 174 | }); 175 | 176 | test("doesn't match outside its range", () async { 177 | expect( 178 | await list('foo/ba[a-x]'), unorderedEquals([p.join('foo', 'bar')])); 179 | }); 180 | 181 | test("doesn't match outside its specific list", () async { 182 | expect( 183 | await list('foo/ba[rx]'), unorderedEquals([p.join('foo', 'bar')])); 184 | }); 185 | }); 186 | 187 | test("the same file shouldn't be non-recursively listed multiple times", 188 | () async { 189 | await d.dir('multi', [ 190 | d.dir('start-end', [d.file('file')]) 191 | ]).create(); 192 | 193 | expect(await list('multi/{start-*/f*,*-end/*e}'), 194 | equals([p.join('multi', 'start-end', 'file')])); 195 | }); 196 | 197 | test("the same file shouldn't be recursively listed multiple times", 198 | () async { 199 | await d.dir('multi', [ 200 | d.dir('a', [ 201 | d.dir('b', [ 202 | d.file('file'), 203 | d.dir('c', [d.file('file')]) 204 | ]), 205 | d.dir('x', [ 206 | d.dir('y', [d.file('file')]) 207 | ]) 208 | ]) 209 | ]).create(); 210 | 211 | expect( 212 | await list('multi/{*/*/*/file,a/**/file}'), 213 | unorderedEquals([ 214 | p.join('multi', 'a', 'b', 'file'), 215 | p.join('multi', 'a', 'b', 'c', 'file'), 216 | p.join('multi', 'a', 'x', 'y', 'file') 217 | ])); 218 | }); 219 | 220 | group('with symlinks', () { 221 | setUp(() async { 222 | await Link(p.join(d.sandbox, 'dir', 'link')) 223 | .create(p.join(d.sandbox, 'foo', 'baz'), recursive: true); 224 | }); 225 | 226 | test('follows symlinks by default', () async { 227 | expect( 228 | await list('dir/**'), 229 | unorderedEquals([ 230 | p.join('dir', 'link'), 231 | p.join('dir', 'link', 'bang'), 232 | p.join('dir', 'link', 'qux') 233 | ])); 234 | }); 235 | 236 | test("doesn't follow symlinks with followLinks: false", () async { 237 | expect(await list('dir/**', followLinks: false), 238 | equals([p.join('dir', 'link')])); 239 | }); 240 | 241 | test("shouldn't crash on broken symlinks", () async { 242 | await Directory(p.join(d.sandbox, 'foo')).delete(recursive: true); 243 | 244 | expect(await list('dir/**'), equals([p.join('dir', 'link')])); 245 | }); 246 | }); 247 | 248 | test('always lists recursively with recursive: true', () async { 249 | expect( 250 | await list('foo', recursive: true), 251 | unorderedEquals([ 252 | 'foo', 253 | p.join('foo', 'bar'), 254 | p.join('foo', 'baz'), 255 | p.join('foo', 'baz', 'qux'), 256 | p.join('foo', 'baz', 'bang') 257 | ])); 258 | }); 259 | 260 | test('lists an absolute glob', () async { 261 | var pattern = 262 | separatorToForwardSlash(p.absolute(p.join(d.sandbox, 'foo/baz/**'))); 263 | 264 | var result = await list(pattern); 265 | 266 | expect( 267 | result, 268 | unorderedEquals( 269 | [p.join('foo', 'baz', 'bang'), p.join('foo', 'baz', 'qux')])); 270 | }); 271 | 272 | // Regression test for #4. 273 | test('lists an absolute case-insensitive glob', () async { 274 | var pattern = 275 | separatorToForwardSlash(p.absolute(p.join(d.sandbox, 'foo/Baz/**'))); 276 | 277 | expect( 278 | await list(pattern, caseSensitive: false), 279 | unorderedEquals( 280 | [p.join('foo', 'baz', 'bang'), p.join('foo', 'baz', 'qux')])); 281 | }); 282 | 283 | test('lists a subdirectory that sometimes exists', () async { 284 | await d.dir('top', [ 285 | d.dir('dir1', [ 286 | d.dir('subdir', [d.file('file')]) 287 | ]), 288 | d.dir('dir2', []) 289 | ]).create(); 290 | 291 | expect(await list('top/*/subdir/**'), 292 | equals([p.join('top', 'dir1', 'subdir', 'file')])); 293 | }); 294 | 295 | group('when case-insensitive', () { 296 | test('lists literals case-insensitively', () async { 297 | expect(await list('foo/baz/qux', caseSensitive: false), 298 | equals([p.join('foo', 'baz', 'qux')])); 299 | expect(await list('foo/BAZ/qux', caseSensitive: false), 300 | equals([p.join('foo', 'baz', 'qux')])); 301 | }); 302 | 303 | test('lists ranges case-insensitively', () async { 304 | expect(await list('foo/[bx][a-z]z/qux', caseSensitive: false), 305 | equals([p.join('foo', 'baz', 'qux')])); 306 | expect(await list('foo/[BX][A-Z]z/qux', caseSensitive: false), 307 | equals([p.join('foo', 'baz', 'qux')])); 308 | }); 309 | 310 | test('options preserve case-insensitivity', () async { 311 | expect(await list('foo/{bar,baz}/qux', caseSensitive: false), 312 | equals([p.join('foo', 'baz', 'qux')])); 313 | expect(await list('foo/{BAR,BAZ}/qux', caseSensitive: false), 314 | equals([p.join('foo', 'baz', 'qux')])); 315 | }); 316 | }); 317 | }); 318 | } 319 | 320 | typedef ListFn = FutureOr> Function(String glob, 321 | {bool recursive, bool followLinks, bool? caseSensitive}); 322 | 323 | /// Runs [callback] in two groups with two values of [ListFn]: one that uses 324 | /// `Glob.list`, one that uses `Glob.listSync`. 325 | void syncAndAsync(FutureOr Function(ListFn) callback) { 326 | group('async', () { 327 | callback((pattern, {recursive = false, followLinks = true, caseSensitive}) { 328 | var glob = 329 | Glob(pattern, recursive: recursive, caseSensitive: caseSensitive); 330 | 331 | return glob 332 | .list(root: d.sandbox, followLinks: followLinks) 333 | .map((entity) => p.relative(entity.path, from: d.sandbox)) 334 | .toList(); 335 | }); 336 | }); 337 | 338 | group('sync', () { 339 | callback((pattern, {recursive = false, followLinks = true, caseSensitive}) { 340 | var glob = 341 | Glob(pattern, recursive: recursive, caseSensitive: caseSensitive); 342 | 343 | return glob 344 | .listSync(root: d.sandbox, followLinks: followLinks) 345 | .map((entity) => p.relative(entity.path, from: d.sandbox)) 346 | .toList(); 347 | }); 348 | }); 349 | } 350 | -------------------------------------------------------------------------------- /test/match_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:glob/glob.dart'; 6 | import 'package:glob/src/utils.dart'; 7 | import 'package:path/path.dart' as p; 8 | import 'package:test/test.dart'; 9 | 10 | const _rawAsciiWithoutSlash = "\t\n\r !\"#\$%&'()*+`-.0123456789:;<=>?@ABCDEF" 11 | 'GHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~'; 12 | 13 | // URL-encode the path for a URL context. 14 | final asciiWithoutSlash = p.style == p.Style.url 15 | ? Uri.encodeFull(_rawAsciiWithoutSlash) 16 | : _rawAsciiWithoutSlash; 17 | 18 | void main() { 19 | test('literals match exactly', () { 20 | expect('foo', contains(Glob('foo'))); 21 | expect('foo/bar', contains(Glob('foo/bar'))); 22 | expect('foo*', contains(Glob(r'foo\*'))); 23 | }); 24 | 25 | test('backslashes match nothing on Windows', () { 26 | expect(r'foo\bar', isNot(contains(Glob(r'foo\\bar', context: p.windows)))); 27 | }); 28 | 29 | group('star', () { 30 | test('matches non-separator characters', () { 31 | var glob = Glob('*'); 32 | expect(asciiWithoutSlash, contains(glob)); 33 | }); 34 | 35 | test('matches the empty string', () { 36 | expect('foo', contains(Glob('foo*'))); 37 | expect('', contains(Glob('*'))); 38 | }); 39 | 40 | test("doesn't match separators", () { 41 | var glob = Glob('*'); 42 | expect('foo/bar', isNot(contains(glob))); 43 | }); 44 | }); 45 | 46 | group('double star', () { 47 | test('matches non-separator characters', () { 48 | var glob = Glob('**'); 49 | expect(asciiWithoutSlash, contains(glob)); 50 | }); 51 | 52 | test('matches the empty string', () { 53 | var glob = Glob('foo**'); 54 | expect('foo', contains(glob)); 55 | }); 56 | 57 | test('matches any level of nesting', () { 58 | var glob = Glob('**'); 59 | expect('a', contains(glob)); 60 | expect('a/b/c/d/e/f', contains(glob)); 61 | }); 62 | 63 | test("doesn't match unresolved dot dots", () { 64 | expect('../foo/bar', isNot(contains(Glob('**')))); 65 | }); 66 | 67 | test('matches entities containing dot dots', () { 68 | expect('..foo/bar', contains(Glob('**'))); 69 | expect('foo../bar', contains(Glob('**'))); 70 | expect('foo/..bar', contains(Glob('**'))); 71 | expect('foo/bar..', contains(Glob('**'))); 72 | }); 73 | }); 74 | 75 | group('any char', () { 76 | test('matches any non-separator character', () { 77 | var glob = Glob('foo?'); 78 | for (var char in _rawAsciiWithoutSlash.split('')) { 79 | if (p.style == p.Style.url) char = Uri.encodeFull(char); 80 | expect('foo$char', contains(glob)); 81 | } 82 | }); 83 | 84 | test("doesn't match a separator", () { 85 | expect('foo/bar', isNot(contains(Glob('foo?bar')))); 86 | }); 87 | }); 88 | 89 | group('range', () { 90 | test('can match individual characters', () { 91 | var glob = Glob('foo[a<.*]'); 92 | expect('fooa', contains(glob)); 93 | expect('foo<', contains(glob)); 94 | expect('foo.', contains(glob)); 95 | expect('foo*', contains(glob)); 96 | expect('foob', isNot(contains(glob))); 97 | expect('foo>', isNot(contains(glob))); 98 | }); 99 | 100 | test('can match a range of characters', () { 101 | var glob = Glob('foo[a-z]'); 102 | expect('fooa', contains(glob)); 103 | expect('foon', contains(glob)); 104 | expect('fooz', contains(glob)); 105 | expect('foo`', isNot(contains(glob))); 106 | expect('foo{', isNot(contains(glob))); 107 | }); 108 | 109 | test('can match multiple ranges of characters', () { 110 | var glob = Glob('foo[a-zA-Z]'); 111 | expect('fooa', contains(glob)); 112 | expect('foon', contains(glob)); 113 | expect('fooz', contains(glob)); 114 | expect('fooA', contains(glob)); 115 | expect('fooN', contains(glob)); 116 | expect('fooZ', contains(glob)); 117 | expect('foo?', isNot(contains(glob))); 118 | expect('foo{', isNot(contains(glob))); 119 | }); 120 | 121 | test('can match individual characters and ranges of characters', () { 122 | var glob = Glob('foo[a-z_A-Z]'); 123 | expect('fooa', contains(glob)); 124 | expect('foon', contains(glob)); 125 | expect('fooz', contains(glob)); 126 | expect('fooA', contains(glob)); 127 | expect('fooN', contains(glob)); 128 | expect('fooZ', contains(glob)); 129 | expect('foo_', contains(glob)); 130 | expect('foo?', isNot(contains(glob))); 131 | expect('foo{', isNot(contains(glob))); 132 | }); 133 | 134 | test('can be negated', () { 135 | var glob = Glob('foo[^a<.*]'); 136 | expect('fooa', isNot(contains(glob))); 137 | expect('foo<', isNot(contains(glob))); 138 | expect('foo.', isNot(contains(glob))); 139 | expect('foo*', isNot(contains(glob))); 140 | expect('foob', contains(glob)); 141 | expect('foo>', contains(glob)); 142 | }); 143 | 144 | test('never matches separators', () { 145 | // "\t-~" contains "/". 146 | expect('foo/bar', isNot(contains(Glob('foo[\t-~]bar')))); 147 | expect('foo/bar', isNot(contains(Glob('foo[^a]bar')))); 148 | }); 149 | 150 | test('allows dangling -', () { 151 | expect('-', contains(Glob(r'[-]'))); 152 | 153 | var glob = Glob(r'[a-]'); 154 | expect('-', contains(glob)); 155 | expect('a', contains(glob)); 156 | 157 | glob = Glob(r'[-b]'); 158 | expect('-', contains(glob)); 159 | expect('b', contains(glob)); 160 | }); 161 | 162 | test('allows multiple -s', () { 163 | expect('-', contains(Glob(r'[--]'))); 164 | expect('-', contains(Glob(r'[---]'))); 165 | 166 | var glob = Glob(r'[--a]'); 167 | expect('-', contains(glob)); 168 | expect('a', contains(glob)); 169 | }); 170 | 171 | test('allows negated /', () { 172 | expect('foo-bar', contains(Glob('foo[^/]bar'))); 173 | }); 174 | 175 | test("doesn't choke on RegExp-active characters", () { 176 | var glob = Glob(r'foo[\]].*'); 177 | expect('foobar', isNot(contains(glob))); 178 | expect('foo].*', contains(glob)); 179 | }); 180 | }); 181 | 182 | group('options', () { 183 | test('match if any of the options match', () { 184 | var glob = Glob('foo/{bar,baz,bang}'); 185 | expect('foo/bar', contains(glob)); 186 | expect('foo/baz', contains(glob)); 187 | expect('foo/bang', contains(glob)); 188 | expect('foo/qux', isNot(contains(glob))); 189 | }); 190 | 191 | test('can contain nested operators', () { 192 | var glob = Glob('foo/{ba?,*az,ban{g,f}}'); 193 | expect('foo/bar', contains(glob)); 194 | expect('foo/baz', contains(glob)); 195 | expect('foo/bang', contains(glob)); 196 | expect('foo/qux', isNot(contains(glob))); 197 | }); 198 | 199 | test('can conditionally match separators', () { 200 | var glob = Glob('foo/{bar,baz/bang}'); 201 | expect('foo/bar', contains(glob)); 202 | expect('foo/baz/bang', contains(glob)); 203 | expect('foo/baz', isNot(contains(glob))); 204 | expect('foo/bar/bang', isNot(contains(glob))); 205 | }); 206 | }); 207 | 208 | group('normalization', () { 209 | test('extra slashes are ignored', () { 210 | expect('foo//bar', contains(Glob('foo/bar'))); 211 | expect('foo/', contains(Glob('*'))); 212 | }); 213 | 214 | test('dot directories are ignored', () { 215 | expect('foo/./bar', contains(Glob('foo/bar'))); 216 | expect('foo/.', contains(Glob('foo'))); 217 | }); 218 | 219 | test('dot dot directories are resolved', () { 220 | expect('foo/../bar', contains(Glob('bar'))); 221 | expect('../foo/bar', contains(Glob('../foo/bar'))); 222 | expect('foo/../../bar', contains(Glob('../bar'))); 223 | }); 224 | 225 | test('Windows separators are converted in a Windows context', () { 226 | expect(r'foo\bar', contains(Glob('foo/bar', context: p.windows))); 227 | expect(r'foo\bar/baz', contains(Glob('foo/bar/baz', context: p.windows))); 228 | }); 229 | }); 230 | 231 | test('an absolute path can be matched by a relative glob', () { 232 | var path = p.absolute('foo/bar'); 233 | expect(path, contains(Glob('foo/bar'))); 234 | }); 235 | 236 | test('a relative path can be matched by an absolute glob', () { 237 | var pattern = separatorToForwardSlash(p.absolute('foo/bar')); 238 | expect('foo/bar', contains(Glob(pattern))); 239 | }, testOn: 'vm'); 240 | 241 | group('with recursive: true', () { 242 | var glob = Glob('foo/bar', recursive: true); 243 | 244 | test('still matches basic files', () { 245 | expect('foo/bar', contains(glob)); 246 | }); 247 | 248 | test('matches subfiles', () { 249 | expect('foo/bar/baz', contains(glob)); 250 | expect('foo/bar/baz/bang', contains(glob)); 251 | }); 252 | 253 | test("doesn't match suffixes", () { 254 | expect('foo/barbaz', isNot(contains(glob))); 255 | expect('foo/barbaz/bang', isNot(contains(glob))); 256 | }); 257 | }); 258 | 259 | test('absolute POSIX paths', () { 260 | expect('/foo/bar', contains(Glob('/foo/bar', context: p.posix))); 261 | expect('/foo/bar', isNot(contains(Glob('**', context: p.posix)))); 262 | expect('/foo/bar', contains(Glob('/**', context: p.posix))); 263 | }); 264 | 265 | test('absolute Windows paths', () { 266 | expect(r'C:\foo\bar', contains(Glob('C:/foo/bar', context: p.windows))); 267 | expect(r'C:\foo\bar', isNot(contains(Glob('**', context: p.windows)))); 268 | expect(r'C:\foo\bar', contains(Glob('C:/**', context: p.windows))); 269 | 270 | expect( 271 | r'\\foo\bar\baz', contains(Glob('//foo/bar/baz', context: p.windows))); 272 | expect(r'\\foo\bar\baz', isNot(contains(Glob('**', context: p.windows)))); 273 | expect(r'\\foo\bar\baz', contains(Glob('//**', context: p.windows))); 274 | expect(r'\\foo\bar\baz', contains(Glob('//foo/**', context: p.windows))); 275 | }); 276 | 277 | test('absolute URL paths', () { 278 | expect(r'http://foo.com/bar', 279 | contains(Glob('http://foo.com/bar', context: p.url))); 280 | expect(r'http://foo.com/bar', isNot(contains(Glob('**', context: p.url)))); 281 | expect(r'http://foo.com/bar', contains(Glob('http://**', context: p.url))); 282 | expect(r'http://foo.com/bar', 283 | contains(Glob('http://foo.com/**', context: p.url))); 284 | 285 | expect('/foo/bar', contains(Glob('/foo/bar', context: p.url))); 286 | expect('/foo/bar', isNot(contains(Glob('**', context: p.url)))); 287 | expect('/foo/bar', contains(Glob('/**', context: p.url))); 288 | }); 289 | 290 | group('when case-sensitive', () { 291 | test('literals match case-sensitively', () { 292 | expect('foo', contains(Glob('foo', caseSensitive: true))); 293 | expect('FOO', isNot(contains(Glob('foo', caseSensitive: true)))); 294 | expect('foo', isNot(contains(Glob('FOO', caseSensitive: true)))); 295 | }); 296 | 297 | test('ranges match case-sensitively', () { 298 | expect('foo', contains(Glob('[fx][a-z]o', caseSensitive: true))); 299 | expect('FOO', isNot(contains(Glob('[fx][a-z]o', caseSensitive: true)))); 300 | expect('foo', isNot(contains(Glob('[FX][A-Z]O', caseSensitive: true)))); 301 | }); 302 | 303 | test('sequences preserve case-sensitivity', () { 304 | expect('foo/bar', contains(Glob('foo/bar', caseSensitive: true))); 305 | expect('FOO/BAR', isNot(contains(Glob('foo/bar', caseSensitive: true)))); 306 | expect('foo/bar', isNot(contains(Glob('FOO/BAR', caseSensitive: true)))); 307 | }); 308 | 309 | test('options preserve case-sensitivity', () { 310 | expect('foo', contains(Glob('{foo,bar}', caseSensitive: true))); 311 | expect('FOO', isNot(contains(Glob('{foo,bar}', caseSensitive: true)))); 312 | expect('foo', isNot(contains(Glob('{FOO,BAR}', caseSensitive: true)))); 313 | }); 314 | }); 315 | 316 | group('when case-insensitive', () { 317 | test('literals match case-insensitively', () { 318 | expect('foo', contains(Glob('foo', caseSensitive: false))); 319 | expect('FOO', contains(Glob('foo', caseSensitive: false))); 320 | expect('foo', contains(Glob('FOO', caseSensitive: false))); 321 | }); 322 | 323 | test('ranges match case-insensitively', () { 324 | expect('foo', contains(Glob('[fx][a-z]o', caseSensitive: false))); 325 | expect('FOO', contains(Glob('[fx][a-z]o', caseSensitive: false))); 326 | expect('foo', contains(Glob('[FX][A-Z]O', caseSensitive: false))); 327 | }); 328 | 329 | test('sequences preserve case-insensitivity', () { 330 | expect('foo/bar', contains(Glob('foo/bar', caseSensitive: false))); 331 | expect('FOO/BAR', contains(Glob('foo/bar', caseSensitive: false))); 332 | expect('foo/bar', contains(Glob('FOO/BAR', caseSensitive: false))); 333 | }); 334 | 335 | test('options preserve case-insensitivity', () { 336 | expect('foo', contains(Glob('{foo,bar}', caseSensitive: false))); 337 | expect('FOO', contains(Glob('{foo,bar}', caseSensitive: false))); 338 | expect('foo', contains(Glob('{FOO,BAR}', caseSensitive: false))); 339 | }); 340 | }); 341 | } 342 | -------------------------------------------------------------------------------- /lib/src/ast.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'package:collection/collection.dart'; 6 | import 'package:path/path.dart' as p; 7 | 8 | import 'utils.dart'; 9 | 10 | const _separator = 0x2F; // "/" 11 | 12 | /// A node in the abstract syntax tree for a glob. 13 | abstract class AstNode { 14 | /// The cached regular expression that this AST was compiled into. 15 | RegExp? _regExp; 16 | 17 | /// Whether this node matches case-sensitively or not. 18 | final bool caseSensitive; 19 | 20 | /// Whether this glob could match an absolute path. 21 | /// 22 | /// Either this or [canMatchRelative] or both will be true. 23 | bool get canMatchAbsolute => false; 24 | 25 | /// Whether this glob could match a relative path. 26 | /// 27 | /// Either this or [canMatchRelative] or both will be true. 28 | bool get canMatchRelative => true; 29 | 30 | AstNode._(this.caseSensitive); 31 | 32 | /// Returns a new glob with all the options bubbled to the top level. 33 | /// 34 | /// In particular, this returns a glob AST with two guarantees: 35 | /// 36 | /// 1. There are no [OptionsNode]s other than the one at the top level. 37 | /// 2. It matches the same set of paths as `this`. 38 | /// 39 | /// For example, given the glob `{foo,bar}/{click/clack}`, this would return 40 | /// `{foo/click,foo/clack,bar/click,bar/clack}`. 41 | OptionsNode flattenOptions() => OptionsNode([ 42 | SequenceNode([this], caseSensitive: caseSensitive) 43 | ], caseSensitive: caseSensitive); 44 | 45 | /// Returns whether this glob matches [string]. 46 | bool matches(String string) => 47 | (_regExp ??= RegExp('^${_toRegExp()}\$', caseSensitive: caseSensitive)) 48 | .hasMatch(string); 49 | 50 | /// Subclasses should override this to return a regular expression component. 51 | String _toRegExp(); 52 | } 53 | 54 | /// A sequence of adjacent AST nodes. 55 | class SequenceNode extends AstNode { 56 | /// The nodes in the sequence. 57 | final List nodes; 58 | 59 | @override 60 | bool get canMatchAbsolute => nodes.first.canMatchAbsolute; 61 | 62 | @override 63 | bool get canMatchRelative => nodes.first.canMatchRelative; 64 | 65 | SequenceNode(Iterable nodes, {bool caseSensitive = true}) 66 | : nodes = nodes.toList(), 67 | super._(caseSensitive); 68 | 69 | @override 70 | OptionsNode flattenOptions() { 71 | if (nodes.isEmpty) { 72 | return OptionsNode([this], caseSensitive: caseSensitive); 73 | } 74 | 75 | var sequences = 76 | nodes.first.flattenOptions().options.map((sequence) => sequence.nodes); 77 | for (var node in nodes.skip(1)) { 78 | // Concatenate all sequences in the next options node ([nextSequences]) 79 | // onto all previous sequences ([sequences]). 80 | var nextSequences = node.flattenOptions().options; 81 | sequences = sequences.expand((sequence) { 82 | return nextSequences.map((nextSequence) { 83 | return sequence.toList()..addAll(nextSequence.nodes); 84 | }); 85 | }); 86 | } 87 | 88 | return OptionsNode(sequences.map((sequence) { 89 | // Combine any adjacent LiteralNodes in [sequence]. 90 | return SequenceNode( 91 | sequence.fold>([], (combined, node) { 92 | if (combined.isEmpty || 93 | combined.last is! LiteralNode || 94 | node is! LiteralNode) { 95 | return combined..add(node); 96 | } 97 | 98 | combined[combined.length - 1] = LiteralNode( 99 | (combined.last as LiteralNode).text + node.text, 100 | caseSensitive: caseSensitive); 101 | return combined; 102 | }), 103 | caseSensitive: caseSensitive); 104 | }), caseSensitive: caseSensitive); 105 | } 106 | 107 | /// Splits this glob into components along its path separators. 108 | /// 109 | /// For example, given the glob `foo/*/*.dart`, this would return three globs: 110 | /// `foo`, `*`, and `*.dart`. 111 | /// 112 | /// Path separators within options nodes are not split. For example, 113 | /// `foo/{bar,baz/bang}/qux` will return three globs: `foo`, `{bar,baz/bang}`, 114 | /// and `qux`. 115 | /// 116 | /// [context] is used to determine what absolute roots look like for this 117 | /// glob. 118 | List split(p.Context context) { 119 | var componentsToReturn = []; 120 | List? currentComponent; 121 | 122 | void addNode(AstNode node) { 123 | (currentComponent ??= []).add(node); 124 | } 125 | 126 | void finishComponent() { 127 | if (currentComponent == null) return; 128 | componentsToReturn 129 | .add(SequenceNode(currentComponent!, caseSensitive: caseSensitive)); 130 | currentComponent = null; 131 | } 132 | 133 | for (var node in nodes) { 134 | if (node is! LiteralNode) { 135 | addNode(node); 136 | continue; 137 | } 138 | 139 | if (!node.text.contains('/')) { 140 | addNode(node); 141 | continue; 142 | } 143 | 144 | var text = node.text; 145 | if (context.style == p.Style.windows) text = text.replaceAll('/', '\\'); 146 | Iterable components = context.split(text); 147 | 148 | // If the first component is absolute, that means it's a separator (on 149 | // Windows some non-separator things are also absolute, but it's invalid 150 | // to have "C:" show up in the middle of a path anyway). 151 | if (context.isAbsolute(components.first)) { 152 | // If this is the first component, it's the root. 153 | if (componentsToReturn.isEmpty && currentComponent == null) { 154 | var root = components.first; 155 | if (context.style == p.Style.windows) { 156 | // Above, we switched to backslashes to make [context.split] handle 157 | // roots properly. That means that if there is a root, it'll still 158 | // have backslashes, where forward slashes are required for globs. 159 | // So we switch it back here. 160 | root = root.replaceAll('\\', '/'); 161 | } 162 | addNode(LiteralNode(root, caseSensitive: caseSensitive)); 163 | } 164 | finishComponent(); 165 | components = components.skip(1); 166 | if (components.isEmpty) continue; 167 | } 168 | 169 | // For each component except the last one, add a separate sequence to 170 | // [sequences] containing only that component. 171 | for (var component in components.take(components.length - 1)) { 172 | addNode(LiteralNode(component, caseSensitive: caseSensitive)); 173 | finishComponent(); 174 | } 175 | 176 | // For the final component, only end its sequence (by adding a new empty 177 | // sequence) if it ends with a separator. 178 | addNode(LiteralNode(components.last, caseSensitive: caseSensitive)); 179 | if (node.text.endsWith('/')) finishComponent(); 180 | } 181 | 182 | finishComponent(); 183 | return componentsToReturn; 184 | } 185 | 186 | @override 187 | String _toRegExp() => nodes.map((node) => node._toRegExp()).join(); 188 | 189 | @override 190 | bool operator ==(Object other) => 191 | other is SequenceNode && 192 | const IterableEquality().equals(nodes, other.nodes); 193 | 194 | @override 195 | int get hashCode => const IterableEquality().hash(nodes); 196 | 197 | @override 198 | String toString() => nodes.join(); 199 | } 200 | 201 | /// A node matching zero or more non-separator characters. 202 | class StarNode extends AstNode { 203 | StarNode({bool caseSensitive = true}) : super._(caseSensitive); 204 | 205 | @override 206 | String _toRegExp() => '[^/]*'; 207 | 208 | @override 209 | bool operator ==(Object other) => other is StarNode; 210 | 211 | @override 212 | int get hashCode => 0; 213 | 214 | @override 215 | String toString() => '*'; 216 | } 217 | 218 | /// A node matching zero or more characters that may be separators. 219 | class DoubleStarNode extends AstNode { 220 | /// The path context for the glob. 221 | /// 222 | /// This is used to determine what absolute paths look like. 223 | final p.Context _context; 224 | 225 | DoubleStarNode(this._context, {bool caseSensitive = true}) 226 | : super._(caseSensitive); 227 | 228 | @override 229 | String _toRegExp() { 230 | // Double star shouldn't match paths with a leading "../", since these paths 231 | // wouldn't be listed with this glob. We only check for "../" at the 232 | // beginning since the paths are normalized before being checked against the 233 | // glob. 234 | var buffer = StringBuffer()..write(r'(?!^(?:\.\./|'); 235 | 236 | // A double star at the beginning of the glob also shouldn't match absolute 237 | // paths, since those also wouldn't be listed. Which root patterns we look 238 | // for depends on the style of path we're matching. 239 | if (_context.style == p.Style.posix) { 240 | buffer.write(r'/'); 241 | } else if (_context.style == p.Style.windows) { 242 | buffer.write(r'//|[A-Za-z]:/'); 243 | } else { 244 | assert(_context.style == p.Style.url); 245 | buffer.write(r'[a-zA-Z][-+.a-zA-Z\d]*://|/'); 246 | } 247 | 248 | // Use `[^]` rather than `.` so that it matches newlines as well. 249 | buffer.write(r'))[^]*'); 250 | 251 | return buffer.toString(); 252 | } 253 | 254 | @override 255 | bool operator ==(Object other) => other is DoubleStarNode; 256 | 257 | @override 258 | int get hashCode => 1; 259 | 260 | @override 261 | String toString() => '**'; 262 | } 263 | 264 | /// A node matching a single non-separator character. 265 | class AnyCharNode extends AstNode { 266 | AnyCharNode({bool caseSensitive = true}) : super._(caseSensitive); 267 | 268 | @override 269 | String _toRegExp() => '[^/]'; 270 | 271 | @override 272 | bool operator ==(Object other) => other is AnyCharNode; 273 | 274 | @override 275 | int get hashCode => 2; 276 | 277 | @override 278 | String toString() => '?'; 279 | } 280 | 281 | /// A node matching a single character in a range of options. 282 | class RangeNode extends AstNode { 283 | /// The ranges matched by this node. 284 | /// 285 | /// The ends of the ranges are unicode code points. 286 | final Set ranges; 287 | 288 | /// Whether this range was negated. 289 | final bool negated; 290 | 291 | RangeNode(Iterable ranges, 292 | {required this.negated, bool caseSensitive = true}) 293 | : ranges = ranges.toSet(), 294 | super._(caseSensitive); 295 | 296 | @override 297 | OptionsNode flattenOptions() { 298 | if (negated || ranges.any((range) => !range.isSingleton)) { 299 | return super.flattenOptions(); 300 | } 301 | 302 | // If a range explicitly lists a set of characters, return each character as 303 | // a separate expansion. 304 | return OptionsNode(ranges.map((range) { 305 | return SequenceNode([ 306 | LiteralNode(String.fromCharCodes([range.min]), 307 | caseSensitive: caseSensitive) 308 | ], caseSensitive: caseSensitive); 309 | }), caseSensitive: caseSensitive); 310 | } 311 | 312 | @override 313 | String _toRegExp() { 314 | var buffer = StringBuffer(); 315 | 316 | var containsSeparator = ranges.any((range) => range.contains(_separator)); 317 | if (!negated && containsSeparator) { 318 | // Add `(?!/)` because ranges are never allowed to match separators. 319 | buffer.write('(?!/)'); 320 | } 321 | 322 | buffer.write('['); 323 | if (negated) { 324 | buffer.write('^'); 325 | // If the range doesn't itself exclude separators, exclude them ourselves, 326 | // since ranges are never allowed to match them. 327 | if (!containsSeparator) buffer.write('/'); 328 | } 329 | 330 | for (var range in ranges) { 331 | var start = String.fromCharCodes([range.min]); 332 | buffer.write(regExpQuote(start)); 333 | if (range.isSingleton) continue; 334 | buffer.write('-'); 335 | buffer.write(regExpQuote(String.fromCharCodes([range.max]))); 336 | } 337 | 338 | buffer.write(']'); 339 | return buffer.toString(); 340 | } 341 | 342 | @override 343 | bool operator ==(Object other) => 344 | other is RangeNode && 345 | other.negated == negated && 346 | const SetEquality().equals(ranges, other.ranges); 347 | 348 | @override 349 | int get hashCode => 350 | (negated ? 1 : 3) * const SetEquality().hash(ranges); 351 | 352 | @override 353 | String toString() { 354 | var buffer = StringBuffer()..write('['); 355 | for (var range in ranges) { 356 | buffer.writeCharCode(range.min); 357 | if (range.isSingleton) continue; 358 | buffer.write('-'); 359 | buffer.writeCharCode(range.max); 360 | } 361 | buffer.write(']'); 362 | return buffer.toString(); 363 | } 364 | } 365 | 366 | /// A node that matches one of several options. 367 | class OptionsNode extends AstNode { 368 | /// The options to match. 369 | final List options; 370 | 371 | @override 372 | bool get canMatchAbsolute => options.any((node) => node.canMatchAbsolute); 373 | 374 | @override 375 | bool get canMatchRelative => options.any((node) => node.canMatchRelative); 376 | 377 | OptionsNode(Iterable options, {bool caseSensitive = true}) 378 | : options = options.toList(), 379 | super._(caseSensitive); 380 | 381 | @override 382 | OptionsNode flattenOptions() => 383 | OptionsNode(options.expand((option) => option.flattenOptions().options), 384 | caseSensitive: caseSensitive); 385 | 386 | @override 387 | String _toRegExp() => 388 | '(?:${options.map((option) => option._toRegExp()).join("|")})'; 389 | 390 | @override 391 | bool operator ==(Object other) => 392 | other is OptionsNode && 393 | const UnorderedIterableEquality() 394 | .equals(options, other.options); 395 | 396 | @override 397 | int get hashCode => 398 | const UnorderedIterableEquality().hash(options); 399 | 400 | @override 401 | String toString() => '{${options.join(',')}}'; 402 | } 403 | 404 | /// A node that matches a literal string. 405 | class LiteralNode extends AstNode { 406 | /// The string to match. 407 | final String text; 408 | 409 | /// The path context for the glob. 410 | /// 411 | /// This is used to determine whether this could match an absolute path. 412 | final p.Context? _context; 413 | 414 | @override 415 | bool get canMatchAbsolute { 416 | var nativeText = 417 | _context!.style == p.Style.windows ? text.replaceAll('/', '\\') : text; 418 | return _context.isAbsolute(nativeText); 419 | } 420 | 421 | @override 422 | bool get canMatchRelative => !canMatchAbsolute; 423 | 424 | LiteralNode(this.text, {p.Context? context, bool caseSensitive = true}) 425 | : _context = context, 426 | super._(caseSensitive); 427 | 428 | @override 429 | String _toRegExp() => regExpQuote(text); 430 | 431 | @override 432 | bool operator ==(Object other) => other is LiteralNode && other.text == text; 433 | 434 | @override 435 | int get hashCode => text.hashCode; 436 | 437 | @override 438 | String toString() => text; 439 | } 440 | -------------------------------------------------------------------------------- /lib/src/list_tree.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | 7 | import 'package:async/async.dart'; 8 | import 'package:file/file.dart'; 9 | import 'package:path/path.dart' as p; 10 | 11 | import 'ast.dart'; 12 | import 'utils.dart'; 13 | 14 | /// The errno for a file or directory not existing on Mac and Linux. 15 | const _enoent = 2; 16 | 17 | /// Another errno we see on Windows when trying to list a non-existent 18 | /// directory. 19 | const _enoentWin = 3; 20 | 21 | /// A structure built from a glob that efficiently lists filesystem entities 22 | /// that match that glob. 23 | /// 24 | /// This structure is designed to list the minimal number of physical 25 | /// directories necessary to find everything that matches the glob. For example, 26 | /// for the glob `foo/{bar,baz}/*`, there's no need to list the working 27 | /// directory or even `foo/`; only `foo/bar` and `foo/baz` should be listed. 28 | /// 29 | /// This works by creating a tree of [_ListTreeNode]s, each of which corresponds 30 | /// to a single of directory nesting in the source glob. Each node has child 31 | /// nodes associated with globs ([_ListTreeNode.children]), as well as its own 32 | /// glob ([_ListTreeNode._validator]) that indicates which entities within that 33 | /// node's directory should be returned. 34 | /// 35 | /// For example, the glob `foo/{*.dart,b*/*.txt}` creates the following tree: 36 | /// 37 | /// . 38 | /// '-- "foo" (validator: "*.dart") 39 | /// '-- "b*" (validator: "*.txt" 40 | /// 41 | /// If a node doesn't have a validator, we know we don't have to list it 42 | /// explicitly. 43 | /// 44 | /// Nodes can also be marked as "recursive", which means they need to be listed 45 | /// recursively (usually to support `**`). In this case, they will have no 46 | /// children; instead, their validator will just encompass the globs that would 47 | /// otherwise be in their children. For example, the glob 48 | /// `foo/{**.dart,bar/*.txt}` creates a recursive node for `foo` with the 49 | /// validator `**.dart,bar/*.txt`. 50 | /// 51 | /// If the glob contains multiple filesystem roots (e.g. `{C:/,D:/}*.dart`), 52 | /// each root will have its own tree of nodes. Relative globs use `.` as their 53 | /// root instead. 54 | class ListTree { 55 | /// A map from filesystem roots to the list tree for those roots. 56 | /// 57 | /// A relative glob will use `.` as its root. 58 | final Map _trees; 59 | 60 | /// Whether paths listed might overlap. 61 | /// 62 | /// If they do, we need to filter out overlapping paths. 63 | final bool _canOverlap; 64 | 65 | /// The file system to operate on. 66 | final FileSystem _fileSystem; 67 | 68 | ListTree._(this._trees, this._fileSystem) 69 | : _canOverlap = _computeCanOverlap(_trees); 70 | 71 | factory ListTree(AstNode glob, FileSystem fileSystem) { 72 | // The first step in constructing a tree from the glob is to simplify the 73 | // problem by eliminating options. [glob.flattenOptions] bubbles all options 74 | // (and certain ranges) up to the top level of the glob so we can deal with 75 | // them one at a time. 76 | var options = glob.flattenOptions(); 77 | var trees = {}; 78 | 79 | for (var option in options.options) { 80 | // Since each option doesn't include its own options, we can safely split 81 | // it into path components. 82 | var components = option.split(p.context); 83 | var firstNode = components.first.nodes.first; 84 | var root = '.'; 85 | 86 | // Determine the root for this option, if it's absolute. If it's not, the 87 | // root's just ".". 88 | if (firstNode is LiteralNode) { 89 | var text = firstNode.text; 90 | // Platform agnostic way of checking for Windows without `dart:io`. 91 | if (p.context == p.windows) text.replaceAll('/', '\\'); 92 | if (p.isAbsolute(text)) { 93 | // If the path is absolute, the root should be the only thing in the 94 | // first component. 95 | assert(components.first.nodes.length == 1); 96 | root = firstNode.text; 97 | components.removeAt(0); 98 | } 99 | } 100 | 101 | _addGlob(root, components, trees); 102 | } 103 | 104 | return ListTree._(trees, fileSystem); 105 | } 106 | 107 | /// Add the glob represented by [components] to the tree under [root]. 108 | static void _addGlob(String root, List components, 109 | Map trees) { 110 | // The first [parent] represents the root directory itself. It may be null 111 | // here if this is the first option with this particular [root]. If so, 112 | // we'll create it below. 113 | // 114 | // As we iterate through [components], [parent] will be set to 115 | // progressively more nested nodes. 116 | var parent = trees[root]; 117 | for (var i = 0; i < components.length; i++) { 118 | var component = components[i]; 119 | var recursive = component.nodes.any((node) => node is DoubleStarNode); 120 | var complete = i == components.length - 1; 121 | 122 | // If the parent node for this level of nesting already exists, the new 123 | // option will be added to it as additional validator options and/or 124 | // additional children. 125 | // 126 | // If the parent doesn't exist, we'll create it in one of the else 127 | // clauses below. 128 | if (parent != null) { 129 | if (parent.isRecursive || recursive) { 130 | // If [component] is recursive, mark [parent] as recursive. This 131 | // will cause all of its children to be folded into its validator. 132 | // If [parent] was already recursive, this is a no-op. 133 | parent.makeRecursive(); 134 | 135 | // Add [component] and everything nested beneath it as an option to 136 | // [parent]. Since [parent] is recursive, it will recursively list 137 | // everything beneath it and filter them with one big glob. 138 | parent.addOption(_join(components.sublist(i))); 139 | return; 140 | } else if (complete) { 141 | // If [component] is the last component, add it to [parent]'s 142 | // validator but not to its children. 143 | parent.addOption(component); 144 | } else { 145 | // On the other hand if there are more components, add [component] 146 | // to [parent]'s children and not its validator. Since we process 147 | // each option's components separately, the same component is never 148 | // both a validator and a child. 149 | var children = parent.children!; 150 | if (!children.containsKey(component)) { 151 | children[component] = _ListTreeNode(); 152 | } 153 | parent = children[component]; 154 | } 155 | } else if (recursive) { 156 | trees[root] = _ListTreeNode.recursive(_join(components.sublist(i))); 157 | return; 158 | } else if (complete) { 159 | trees[root] = _ListTreeNode()..addOption(component); 160 | } else { 161 | var rootNode = _ListTreeNode(); 162 | trees[root] = rootNode; 163 | var rootChildren = rootNode.children!; 164 | rootChildren[component] = _ListTreeNode(); 165 | parent = rootChildren[component]; 166 | } 167 | } 168 | } 169 | 170 | /// Computes the value for [_canOverlap]. 171 | static bool _computeCanOverlap(Map trees) { 172 | // If this can list a relative path and an absolute path, the former may be 173 | // contained within the latter. 174 | if (trees.length > 1 && trees.containsKey('.')) return true; 175 | 176 | // Otherwise, this can only overlap if the tree beneath any given root could 177 | // overlap internally. 178 | return trees.values.any((node) => node.canOverlap); 179 | } 180 | 181 | /// List all entities that match this glob beneath [root]. 182 | Stream list({String? root, bool followLinks = true}) { 183 | root ??= '.'; 184 | var group = StreamGroup(); 185 | for (var rootDir in _trees.keys) { 186 | var dir = rootDir == '.' ? root : rootDir; 187 | group.add( 188 | _trees[rootDir]!.list(dir, _fileSystem, followLinks: followLinks)); 189 | } 190 | group.close(); 191 | 192 | if (!_canOverlap) return group.stream; 193 | 194 | // TODO: Rather than filtering here, avoid double-listing directories 195 | // in the first place. 196 | var seen = {}; 197 | return group.stream.where((entity) => seen.add(entity.path)); 198 | } 199 | 200 | /// Synchronosuly list all entities that match this glob beneath [root]. 201 | List listSync({String? root, bool followLinks = true}) { 202 | root ??= '.'; 203 | var result = _trees.keys.expand((rootDir) { 204 | var dir = rootDir == '.' ? root! : rootDir; 205 | return _trees[rootDir]! 206 | .listSync(dir, _fileSystem, followLinks: followLinks); 207 | }); 208 | 209 | if (!_canOverlap) return result.toList(); 210 | 211 | // TODO: Rather than filtering here, avoid double-listing directories 212 | // in the first place. 213 | var seen = {}; 214 | return result.where((entity) => seen.add(entity.path)).toList(); 215 | } 216 | } 217 | 218 | /// A single node in a [ListTree]. 219 | class _ListTreeNode { 220 | /// This node's child nodes, by their corresponding globs. 221 | /// 222 | /// Each child node will only be listed on directories that match its glob. 223 | /// 224 | /// This may be `null`, indicating that this node should be listed 225 | /// recursively. 226 | Map? children; 227 | 228 | /// This node's validator. 229 | /// 230 | /// This determines which entities will ultimately be emitted when [list] is 231 | /// called. 232 | OptionsNode? _validator; 233 | 234 | /// Whether this node is recursive. 235 | /// 236 | /// A recursive node has no children and is listed recursively. 237 | bool get isRecursive => children == null; 238 | 239 | bool get _caseSensitive { 240 | if (_validator != null) return _validator!.caseSensitive; 241 | if (children?.isEmpty != false) return true; 242 | return children!.keys.first.caseSensitive; 243 | } 244 | 245 | /// Whether this node doesn't itself need to be listed. 246 | /// 247 | /// If a node has no validator and all of its children are literal filenames, 248 | /// there's no need to list its contents. We can just directly traverse into 249 | /// its children. 250 | bool get _isIntermediate { 251 | if (_validator != null) return false; 252 | return children!.keys.every((sequence) => 253 | sequence.nodes.length == 1 && sequence.nodes.first is LiteralNode); 254 | } 255 | 256 | /// Returns whether listing this node might return overlapping results. 257 | bool get canOverlap { 258 | // A recusive node can never overlap with itself, because it will only ever 259 | // involve a single call to [Directory.list] that's then filtered with 260 | // [_validator]. 261 | if (isRecursive) return false; 262 | 263 | // If there's more than one child node and at least one of the children is 264 | // dynamic (that is, matches more than just a literal string), there may be 265 | // overlap. 266 | if (children!.length > 1) { 267 | // Case-insensitivity means that even literals may match multiple entries. 268 | if (!_caseSensitive) return true; 269 | 270 | if (children!.keys.any((sequence) => 271 | sequence.nodes.length > 1 || sequence.nodes.single is! LiteralNode)) { 272 | return true; 273 | } 274 | } 275 | 276 | return children!.values.any((node) => node.canOverlap); 277 | } 278 | 279 | /// Creates a node with no children and no validator. 280 | _ListTreeNode() 281 | : children = {}, 282 | _validator = null; 283 | 284 | /// Creates a recursive node the given [validator]. 285 | _ListTreeNode.recursive(SequenceNode validator) 286 | : children = null, 287 | _validator = 288 | OptionsNode([validator], caseSensitive: validator.caseSensitive); 289 | 290 | /// Transforms this into recursive node, folding all its children into its 291 | /// validator. 292 | void makeRecursive() { 293 | if (isRecursive) return; 294 | var children = this.children!; 295 | _validator = OptionsNode(children.entries.map((entry) { 296 | entry.value.makeRecursive(); 297 | return _join([entry.key, entry.value._validator!]); 298 | }), caseSensitive: _caseSensitive); 299 | this.children = null; 300 | } 301 | 302 | /// Adds [validator] to this node's existing validator. 303 | void addOption(SequenceNode validator) { 304 | if (_validator == null) { 305 | _validator = 306 | OptionsNode([validator], caseSensitive: validator.caseSensitive); 307 | } else { 308 | _validator!.options.add(validator); 309 | } 310 | } 311 | 312 | /// Lists all entities within [dir] matching this node or its children. 313 | /// 314 | /// This may return duplicate entities. These will be filtered out in 315 | /// [ListTree.list]. 316 | Stream list(String dir, FileSystem fileSystem, 317 | {bool followLinks = true}) { 318 | if (isRecursive) { 319 | return fileSystem 320 | .directory(dir) 321 | .list(recursive: true, followLinks: followLinks) 322 | .ignoreMissing() 323 | .where((entity) => _matches(p.relative(entity.path, from: dir))); 324 | } 325 | 326 | // Don't spawn extra [Directory.list] calls when we already know exactly 327 | // which subdirectories we're interested in. 328 | if (_isIntermediate && _caseSensitive) { 329 | var resultGroup = StreamGroup(); 330 | children!.forEach((sequence, child) { 331 | resultGroup.add(child.list( 332 | p.join(dir, (sequence.nodes.single as LiteralNode).text), 333 | fileSystem, 334 | followLinks: followLinks)); 335 | }); 336 | resultGroup.close(); 337 | return resultGroup.stream; 338 | } 339 | 340 | return StreamCompleter.fromFuture(() async { 341 | var entities = await fileSystem 342 | .directory(dir) 343 | .list(followLinks: followLinks) 344 | .ignoreMissing() 345 | .toList(); 346 | await _validateIntermediateChildrenAsync(dir, entities, fileSystem); 347 | 348 | var resultGroup = StreamGroup(); 349 | var resultController = StreamController(sync: true); 350 | unawaited(resultGroup.add(resultController.stream)); 351 | for (var entity in entities) { 352 | var basename = p.relative(entity.path, from: dir); 353 | if (_matches(basename)) resultController.add(entity); 354 | 355 | children!.forEach((sequence, child) { 356 | if (entity is! Directory) return; 357 | if (!sequence.matches(basename)) return; 358 | var stream = child.list(p.join(dir, basename), fileSystem, 359 | followLinks: followLinks); 360 | resultGroup.add(stream); 361 | }); 362 | } 363 | unawaited(resultController.close()); 364 | unawaited(resultGroup.close()); 365 | return resultGroup.stream; 366 | }()); 367 | } 368 | 369 | /// If this is a case-insensitive list, validates that all intermediate 370 | /// children (according to [_isIntermediate]) match at least one entity in 371 | /// [entities]. 372 | /// 373 | /// This ensures that listing "foo/bar/*" fails on case-sensitive systems if 374 | /// "foo/bar" doesn't exist. 375 | Future _validateIntermediateChildrenAsync(String dir, 376 | List entities, FileSystem fileSystem) async { 377 | if (_caseSensitive) return; 378 | 379 | for (var entry in children!.entries) { 380 | var child = entry.value; 381 | var sequence = entry.key; 382 | if (!child._isIntermediate) continue; 383 | if (entities.any( 384 | (entity) => sequence.matches(p.relative(entity.path, from: dir)))) { 385 | continue; 386 | } 387 | 388 | // We know this will fail, we're just doing it to force dart:io to emit 389 | // the exception it would if we were listing case-sensitively. 390 | await child 391 | .list(p.join(dir, (sequence.nodes.single as LiteralNode).text), 392 | fileSystem) 393 | .toList(); 394 | } 395 | } 396 | 397 | /// Synchronously lists all entities within [dir] matching this node or its 398 | /// children. 399 | /// 400 | /// This may return duplicate entities. These will be filtered out in 401 | /// [ListTree.listSync]. 402 | Iterable listSync(String dir, FileSystem fileSystem, 403 | {bool followLinks = true}) { 404 | if (isRecursive) { 405 | try { 406 | return fileSystem 407 | .directory(dir) 408 | .listSync(recursive: true, followLinks: followLinks) 409 | .where((entity) => _matches(p.relative(entity.path, from: dir))); 410 | } on FileSystemException catch (error) { 411 | if (error.isMissing) return const []; 412 | rethrow; 413 | } 414 | } 415 | 416 | // Don't spawn extra [Directory.listSync] calls when we already know exactly 417 | // which subdirectories we're interested in. 418 | if (_isIntermediate && _caseSensitive) { 419 | return children!.entries.expand((entry) { 420 | var sequence = entry.key; 421 | var child = entry.value; 422 | return child.listSync( 423 | p.join(dir, (sequence.nodes.single as LiteralNode).text), 424 | fileSystem, 425 | followLinks: followLinks); 426 | }); 427 | } 428 | 429 | List entities; 430 | try { 431 | entities = fileSystem.directory(dir).listSync(followLinks: followLinks); 432 | } on FileSystemException catch (error) { 433 | if (error.isMissing) return const []; 434 | rethrow; 435 | } 436 | _validateIntermediateChildrenSync(dir, entities, fileSystem); 437 | 438 | return entities.expand((entity) { 439 | var entities = []; 440 | var basename = p.relative(entity.path, from: dir); 441 | if (_matches(basename)) entities.add(entity); 442 | if (entity is! Directory) return entities; 443 | 444 | entities.addAll(children!.keys 445 | .where((sequence) => sequence.matches(basename)) 446 | .expand((sequence) { 447 | return children![sequence]! 448 | .listSync(p.join(dir, basename), fileSystem, 449 | followLinks: followLinks) 450 | .toList(); 451 | })); 452 | 453 | return entities; 454 | }); 455 | } 456 | 457 | /// If this is a case-insensitive list, validates that all intermediate 458 | /// children (according to [_isIntermediate]) match at least one entity in 459 | /// [entities]. 460 | /// 461 | /// This ensures that listing "foo/bar/*" fails on case-sensitive systems if 462 | /// "foo/bar" doesn't exist. 463 | void _validateIntermediateChildrenSync( 464 | String dir, List entities, FileSystem fileSystem) { 465 | if (_caseSensitive) return; 466 | 467 | children!.forEach((sequence, child) { 468 | if (!child._isIntermediate) return; 469 | if (entities.any( 470 | (entity) => sequence.matches(p.relative(entity.path, from: dir)))) { 471 | return; 472 | } 473 | 474 | // If there are no [entities] that match [sequence], manually list the 475 | // directory to force `dart:io` to throw an error. This allows us to 476 | // ensure that listing "foo/bar/*" fails on case-sensitive systems if 477 | // "foo/bar" doesn't exist. 478 | child.listSync( 479 | p.join(dir, (sequence.nodes.single as LiteralNode).text), fileSystem); 480 | }); 481 | } 482 | 483 | /// Returns whether the native [path] matches [_validator]. 484 | bool _matches(String path) => 485 | _validator?.matches(toPosixPath(p.context, path)) ?? false; 486 | 487 | @override 488 | String toString() => '($_validator) $children'; 489 | } 490 | 491 | /// Joins each [components] into a new glob where each component is separated by 492 | /// a path separator. 493 | SequenceNode _join(Iterable components) { 494 | var componentsList = components.toList(); 495 | var first = componentsList.removeAt(0); 496 | var nodes = [first]; 497 | for (var component in componentsList) { 498 | nodes.add(LiteralNode('/', caseSensitive: first.caseSensitive)); 499 | nodes.add(component); 500 | } 501 | return SequenceNode(nodes, caseSensitive: first.caseSensitive); 502 | } 503 | 504 | extension on Stream { 505 | Stream ignoreMissing() => handleError((_) {}, 506 | test: (error) => error is FileSystemException && error.isMissing); 507 | } 508 | 509 | extension on FileSystemException { 510 | bool get isMissing { 511 | final errorCode = osError?.errorCode; 512 | return errorCode == _enoent || errorCode == _enoentWin; 513 | } 514 | } 515 | --------------------------------------------------------------------------------