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