Update to null safety, change list* apis (#39)

Also renames the list* apis on Glob 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. Most migrations should be just importing this new file.

Note that when calling listFileSystem if the file system has changed then I chose to just throw away the old _ListTree. We could cache these by file system but I worry about memory leaks if people pass new instances of file systems repeatedly on the same glob.
diff --git a/.travis.yml b/.travis.yml
index 9adf49d..5a9725d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,7 +2,6 @@
 
 dart:
   - dev
-  - 2.2.0
 
 # See https://docs.travis-ci.com/user/languages/dart/ for details.
 dart_task:
@@ -11,9 +10,6 @@
 
 jobs:
   include:
-    - dart: 2.2.0
-      dart_task:
-        test: -p node
     - dart: dev
       dart_task:
         test: -p node
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d60efb..54ca50f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+## 2.0.0-nullsafety.dev
+
+* Migrate to null_safety
+
+### 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.0
 
 * Support running on Node.js.
diff --git a/lib/glob.dart b/lib/glob.dart
index 75bedd6..c241057 100644
--- a/lib/glob.dart
+++ b/lib/glob.dart
@@ -2,10 +2,11 @@
 // 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/io.dart';
 import 'src/list_tree.dart';
 import 'src/parser.dart';
 import 'src/utils.dart';
@@ -47,25 +48,34 @@
   /// The parsed AST of the glob.
   final AstNode _ast;
 
-  ListTree _listTree;
+  /// The underlying object used to implement [list] and [listSync].
+  ///
+  /// 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;
+  bool? _contextIsAbsoluteCache;
 
   /// Whether [pattern] could match absolute paths.
   bool get _patternCanMatchAbsolute =>
       _patternCanMatchAbsoluteCache ??= _ast.canMatchAbsolute;
 
-  bool _patternCanMatchAbsoluteCache;
+  bool? _patternCanMatchAbsoluteCache;
 
   /// Whether [pattern] could match relative paths.
   bool get _patternCanMatchRelative =>
       _patternCanMatchRelativeCache ??= _ast.canMatchRelative;
 
-  bool _patternCanMatchRelativeCache;
+  bool? _patternCanMatchRelativeCache;
 
   /// Returns [contents] with characters that are meaningful in globs
   /// backslash-escaped.
@@ -85,7 +95,7 @@
   /// 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}) {
+      {p.Context? context, bool recursive = false, bool? caseSensitive}) {
     context ??= p.context;
     caseSensitive ??= context.style == p.Style.windows ? false : true;
     if (recursive) pattern += '{,/**}';
@@ -96,7 +106,8 @@
 
   Glob._(this.pattern, this.context, this._ast, this.recursive);
 
-  /// Lists all [FileSystemEntity]s beneath [root] that match the glob.
+  /// 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
@@ -106,18 +117,19 @@
   /// [root] defaults to the current working directory.
   ///
   /// [followLinks] works the same as for [Directory.list].
-  Stream<FileSystemEntity> list({String root, bool followLinks = true}) {
+  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.');
     }
 
-    _listTree ??= ListTree(_ast);
-    return _listTree.list(root: root, followLinks: followLinks);
+    return _listTreeForFileSystem(fileSystem)
+        .list(root: root, followLinks: followLinks);
   }
 
   /// Synchronously lists all [FileSystemEntity]s beneath [root] that match the
-  /// glob.
+  /// 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
@@ -127,21 +139,22 @@
   /// [root] defaults to the current working directory.
   ///
   /// [followLinks] works the same as for [Directory.list].
-  List<FileSystemEntity> listSync({String root, bool followLinks = true}) {
+  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.');
     }
 
-    _listTree ??= ListTree(_ast);
-    return _listTree.listSync(root: root, followLinks: followLinks);
+    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]) {
+  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;
@@ -172,4 +185,18 @@
 
   @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/lib/list_local_fs.dart b/lib/list_local_fs.dart
new file mode 100644
index 0000000..946596a
--- /dev/null
+++ b/lib/list_local_fs.dart
@@ -0,0 +1,23 @@
+// 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 'package:glob/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/lib/src/ast.dart b/lib/src/ast.dart
index 1ee0fcf..ac283d5 100644
--- a/lib/src/ast.dart
+++ b/lib/src/ast.dart
@@ -12,7 +12,7 @@
 /// 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;
+  RegExp? _regExp;
 
   /// Whether this node matches case-sensitively or not.
   final bool caseSensitive;
@@ -43,10 +43,9 @@
       ], caseSensitive: caseSensitive);
 
   /// Returns whether this glob matches [string].
-  bool matches(String string) {
-    _regExp ??= RegExp('^${_toRegExp()}\$', caseSensitive: caseSensitive);
-    return _regExp.hasMatch(string);
-  }
+  bool matches(String string) =>
+      (_regExp ??= RegExp('^${_toRegExp()}\$', caseSensitive: caseSensitive))
+          .hasMatch(string);
 
   /// Subclasses should override this to return a regular expression component.
   String _toRegExp();
@@ -97,9 +96,7 @@
             }
 
             combined[combined.length - 1] = LiteralNode(
-                // TODO(nweiz): Avoid casting when sdk#25565 is fixed.
-                (combined.last as LiteralNode).text +
-                    (node as LiteralNode).text,
+                (combined.last as LiteralNode).text + node.text,
                 caseSensitive: caseSensitive);
             return combined;
           }),
@@ -120,17 +117,16 @@
   /// glob.
   List<SequenceNode> split(p.Context context) {
     var componentsToReturn = <SequenceNode>[];
-    List<AstNode> currentComponent;
+    List<AstNode>? currentComponent;
 
     void addNode(AstNode node) {
-      currentComponent ??= [];
-      currentComponent.add(node);
+      (currentComponent ??= []).add(node);
     }
 
     void finishComponent() {
       if (currentComponent == null) return;
       componentsToReturn
-          .add(SequenceNode(currentComponent, caseSensitive: caseSensitive));
+          .add(SequenceNode(currentComponent!, caseSensitive: caseSensitive));
       currentComponent = null;
     }
 
@@ -140,14 +136,12 @@
         continue;
       }
 
-      // TODO(nweiz): Avoid casting when sdk#25565 is fixed.
-      var literal = node as LiteralNode;
-      if (!literal.text.contains('/')) {
-        addNode(literal);
+      if (!node.text.contains('/')) {
+        addNode(node);
         continue;
       }
 
-      var text = literal.text;
+      var text = node.text;
       if (context.style == p.Style.windows) text = text.replaceAll('/', '\\');
       Iterable<String> components = context.split(text);
 
@@ -182,7 +176,7 @@
       // 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 (literal.text.endsWith('/')) finishComponent();
+      if (node.text.endsWith('/')) finishComponent();
     }
 
     finishComponent();
@@ -294,7 +288,8 @@
   /// Whether this range was negated.
   final bool negated;
 
-  RangeNode(Iterable<Range> ranges, {this.negated, bool caseSensitive = true})
+  RangeNode(Iterable<Range> ranges,
+      {required this.negated, bool caseSensitive = true})
       : ranges = ranges.toSet(),
         super._(caseSensitive);
 
@@ -411,19 +406,19 @@
   /// The path context for the glob.
   ///
   /// This is used to determine whether this could match an absolute path.
-  final p.Context _context;
+  final p.Context? _context;
 
   @override
   bool get canMatchAbsolute {
     var nativeText =
-        _context.style == p.Style.windows ? text.replaceAll('/', '\\') : text;
-    return _context.isAbsolute(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})
+  LiteralNode(this.text, {p.Context? context, bool caseSensitive = true})
       : _context = context,
         super._(caseSensitive);
 
diff --git a/lib/src/io.dart b/lib/src/io.dart
deleted file mode 100644
index 20e532b..0000000
--- a/lib/src/io.dart
+++ /dev/null
@@ -1,11 +0,0 @@
-// Copyright (c) 2019, 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.
-
-// These libraries don't expose *exactly* the same API, but they overlap in all
-// the cases we care about.
-export 'io_export.dart'
-    // We don't actually support the web - exporting dart:io gives a reasonably
-    // clear signal to users about that since it doesn't exist.
-    if (dart.library.html) 'io_export.dart'
-    if (dart.library.js) 'package:node_io/node_io.dart';
diff --git a/lib/src/io_export.dart b/lib/src/io_export.dart
deleted file mode 100644
index 7f339cf..0000000
--- a/lib/src/io_export.dart
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (c) 2019, 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.
-
-/// This library exists only to satisfy build_runner, which doesn't allow
-/// sdk libraries to be conditional imported or exported directly.
-library glob.src.io_export;
-
-export 'dart:io';
diff --git a/lib/src/list_tree.dart b/lib/src/list_tree.dart
index 82f12c5..5d362a9 100644
--- a/lib/src/list_tree.dart
+++ b/lib/src/list_tree.dart
@@ -5,11 +5,11 @@
 import 'dart:async';
 
 import 'package:async/async.dart';
+import 'package:file/file.dart';
 import 'package:path/path.dart' as p;
 import 'package:pedantic/pedantic.dart';
 
 import 'ast.dart';
-import 'io.dart';
 import 'utils.dart';
 
 /// The errno for a file or directory not existing on Mac and Linux.
@@ -56,19 +56,26 @@
   /// A map from filesystem roots to the list tree for those roots.
   ///
   /// A relative glob will use `.` as its root.
-  final _trees = <String, _ListTreeNode>{};
+  final Map<String, _ListTreeNode> _trees;
 
   /// Whether paths listed might overlap.
   ///
   /// If they do, we need to filter out overlapping paths.
-  bool _canOverlap;
+  final bool _canOverlap;
 
-  ListTree(AstNode glob) {
+  /// 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
@@ -81,7 +88,8 @@
       // root's just ".".
       if (firstNode is LiteralNode) {
         var text = firstNode.text;
-        if (Platform.isWindows) text.replaceAll('/', '\\');
+        // 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.
@@ -91,21 +99,22 @@
         }
       }
 
-      _addGlob(root, components);
+      _addGlob(root, components, trees);
     }
 
-    _canOverlap = _computeCanOverlap();
+    return ListTree._(trees, fileSystem);
   }
 
   /// Add the glob represented by [components] to the tree under [root].
-  void _addGlob(String root, List<SequenceNode> components) {
+  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];
+    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);
@@ -138,42 +147,46 @@
           // 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.
-          if (!parent.children.containsKey(component)) {
-            parent.children[component] = _ListTreeNode();
+          var children = parent.children!;
+          if (!children.containsKey(component)) {
+            children[component] = _ListTreeNode();
           }
-          parent = parent.children[component];
+          parent = children[component];
         }
       } else if (recursive) {
-        _trees[root] = _ListTreeNode.recursive(_join(components.sublist(i)));
+        trees[root] = _ListTreeNode.recursive(_join(components.sublist(i)));
         return;
       } else if (complete) {
-        _trees[root] = _ListTreeNode()..addOption(component);
+        trees[root] = _ListTreeNode()..addOption(component);
       } else {
-        _trees[root] = _ListTreeNode();
-        _trees[root].children[component] = _ListTreeNode();
-        parent = _trees[root].children[component];
+        var rootNode = _ListTreeNode();
+        trees[root] = rootNode;
+        var rootChildren = rootNode.children!;
+        rootChildren[component] = _ListTreeNode();
+        parent = rootChildren[component];
       }
     }
   }
 
   /// Computes the value for [_canOverlap].
-  bool _computeCanOverlap() {
+  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;
+    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);
+    return trees.values.any((node) => node.canOverlap);
   }
 
   /// List all entities that match this glob beneath [root].
-  Stream<FileSystemEntity> list({String root, bool followLinks = true}) {
+  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, followLinks: followLinks));
+      group.add(
+          _trees[rootDir]!.list(dir, _fileSystem, followLinks: followLinks));
     }
     group.close();
 
@@ -186,11 +199,12 @@
   }
 
   /// Synchronosuly list all entities that match this glob beneath [root].
-  List<FileSystemEntity> listSync({String root, bool followLinks = true}) {
+  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, followLinks: followLinks);
+      var dir = rootDir == '.' ? root! : rootDir;
+      return _trees[rootDir]!
+          .listSync(dir, _fileSystem, followLinks: followLinks);
     });
 
     if (!_canOverlap) return result.toList();
@@ -210,13 +224,13 @@
   ///
   /// This may be `null`, indicating that this node should be listed
   /// recursively.
-  Map<SequenceNode, _ListTreeNode> children;
+  Map<SequenceNode, _ListTreeNode>? children;
 
   /// This node's validator.
   ///
   /// This determines which entities will ultimately be emitted when [list] is
   /// called.
-  OptionsNode _validator;
+  OptionsNode? _validator;
 
   /// Whether this node is recursive.
   ///
@@ -224,10 +238,9 @@
   bool get isRecursive => children == null;
 
   bool get _caseSensitive {
-    if (_validator != null) return _validator.caseSensitive;
-    if (children == null) return true;
-    if (children.isEmpty) return true;
-    return children.keys.first.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.
@@ -237,7 +250,7 @@
   /// its children.
   bool get _isIntermediate {
     if (_validator != null) return false;
-    return children.keys.every((sequence) =>
+    return children!.keys.every((sequence) =>
         sequence.nodes.length == 1 && sequence.nodes.first is LiteralNode);
   }
 
@@ -251,17 +264,17 @@
     // 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) {
+    if (children!.length > 1) {
       // Case-insensitivity means that even literals may match multiple entries.
       if (!_caseSensitive) return true;
 
-      if (children.keys.any((sequence) =>
+      if (children!.keys.any((sequence) =>
           sequence.nodes.length > 1 || sequence.nodes.single is! LiteralNode)) {
         return true;
       }
     }
 
-    return children.values.any((node) => node.canOverlap);
+    return children!.values.any((node) => node.canOverlap);
   }
 
   /// Creates a node with no children and no validator.
@@ -279,12 +292,12 @@
   /// validator.
   void makeRecursive() {
     if (isRecursive) return;
-    _validator = OptionsNode(children.keys.map((sequence) {
-      var child = children[sequence];
-      child.makeRecursive();
-      return _join([sequence, child._validator]);
+    var children = this.children!;
+    _validator = OptionsNode(children.entries.map((entry) {
+      entry.value.makeRecursive();
+      return _join([entry.key, entry.value._validator!]);
     }), caseSensitive: _caseSensitive);
-    children = null;
+    this.children = null;
   }
 
   /// Adds [validator] to this node's existing validator.
@@ -293,7 +306,7 @@
       _validator =
           OptionsNode([validator], caseSensitive: validator.caseSensitive);
     } else {
-      _validator.options.add(validator);
+      _validator!.options.add(validator);
     }
   }
 
@@ -301,9 +314,11 @@
   ///
   /// This may return duplicate entities. These will be filtered out in
   /// [ListTree.list].
-  Stream<FileSystemEntity> list(String dir, {bool followLinks = true}) {
+  Stream<FileSystemEntity> list(String dir, FileSystem fileSystem,
+      {bool followLinks = true}) {
     if (isRecursive) {
-      return Directory(dir)
+      return fileSystem
+          .directory(dir)
           .list(recursive: true, followLinks: followLinks)
           .where((entity) => _matches(p.relative(entity.path, from: dir)));
     }
@@ -312,9 +327,10 @@
     // which subdirectories we're interested in.
     if (_isIntermediate && _caseSensitive) {
       var resultGroup = StreamGroup<FileSystemEntity>();
-      children.forEach((sequence, child) {
+      children!.forEach((sequence, child) {
         resultGroup.add(child.list(
             p.join(dir, (sequence.nodes.single as LiteralNode).text),
+            fileSystem,
             followLinks: followLinks));
       });
       resultGroup.close();
@@ -322,9 +338,11 @@
     }
 
     return StreamCompleter.fromFuture(() async {
-      var entities =
-          await Directory(dir).list(followLinks: followLinks).toList();
-      await _validateIntermediateChildrenAsync(dir, entities);
+      var entities = await fileSystem
+          .directory(dir)
+          .list(followLinks: followLinks)
+          .toList();
+      await _validateIntermediateChildrenAsync(dir, entities, fileSystem);
 
       var resultGroup = StreamGroup<FileSystemEntity>();
       var resultController = StreamController<FileSystemEntity>(sync: true);
@@ -333,19 +351,19 @@
         var basename = p.relative(entity.path, from: dir);
         if (_matches(basename)) resultController.add(entity);
 
-        children.forEach((sequence, child) {
+        children!.forEach((sequence, child) {
           if (entity is! Directory) return;
           if (!sequence.matches(basename)) return;
           var stream = child
-              .list(p.join(dir, basename), followLinks: followLinks)
+              .list(p.join(dir, basename), fileSystem, followLinks: followLinks)
               .handleError((_) {}, test: (error) {
             // Ignore errors from directories not existing. We do this here so
             // that we only ignore warnings below wild cards. For example, the
             // glob "foo/bar/*/baz" should fail if "foo/bar" doesn't exist but
             // succeed if "foo/bar/qux/baz" doesn't exist.
             return error is FileSystemException &&
-                (error.osError.errorCode == _enoent ||
-                    error.osError.errorCode == _enoentWin);
+                (error.osError!.errorCode == _enoent ||
+                    error.osError!.errorCode == _enoentWin);
           });
           resultGroup.add(stream);
         });
@@ -362,12 +380,13 @@
   ///
   /// This ensures that listing "foo/bar/*" fails on case-sensitive systems if
   /// "foo/bar" doesn't exist.
-  Future _validateIntermediateChildrenAsync(
-      String dir, List<FileSystemEntity> entities) async {
+  Future _validateIntermediateChildrenAsync(String dir,
+      List<FileSystemEntity> entities, FileSystem fileSystem) async {
     if (_caseSensitive) return;
 
-    for (var sequence in children.keys) {
-      var child = children[sequence];
+    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)))) {
@@ -377,7 +396,8 @@
       // 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))
+          .list(p.join(dir, (sequence.nodes.single as LiteralNode).text),
+              fileSystem)
           .toList();
     }
   }
@@ -387,9 +407,11 @@
   ///
   /// This may return duplicate entities. These will be filtered out in
   /// [ListTree.listSync].
-  Iterable<FileSystemEntity> listSync(String dir, {bool followLinks = true}) {
+  Iterable<FileSystemEntity> listSync(String dir, FileSystem fileSystem,
+      {bool followLinks = true}) {
     if (isRecursive) {
-      return Directory(dir)
+      return fileSystem
+          .directory(dir)
           .listSync(recursive: true, followLinks: followLinks)
           .where((entity) => _matches(p.relative(entity.path, from: dir)));
     }
@@ -397,15 +419,18 @@
     // Don't spawn extra [Directory.listSync] calls when we already know exactly
     // which subdirectories we're interested in.
     if (_isIntermediate && _caseSensitive) {
-      return children.keys.expand((sequence) {
-        return children[sequence].listSync(
+      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);
       });
     }
 
-    var entities = Directory(dir).listSync(followLinks: followLinks);
-    _validateIntermediateChildrenSync(dir, entities);
+    var entities = fileSystem.directory(dir).listSync(followLinks: followLinks);
+    _validateIntermediateChildrenSync(dir, entities, fileSystem);
 
     return entities.expand((entity) {
       var entities = <FileSystemEntity>[];
@@ -413,20 +438,21 @@
       if (_matches(basename)) entities.add(entity);
       if (entity is! Directory) return entities;
 
-      entities.addAll(children.keys
+      entities.addAll(children!.keys
           .where((sequence) => sequence.matches(basename))
           .expand((sequence) {
         try {
-          return children[sequence]
-              .listSync(p.join(dir, basename), followLinks: followLinks)
+          return children![sequence]!
+              .listSync(p.join(dir, basename), fileSystem,
+                  followLinks: followLinks)
               .toList();
         } on FileSystemException catch (error) {
           // Ignore errors from directories not existing. We do this here so
           // that we only ignore warnings below wild cards. For example, the
           // glob "foo/bar/*/baz" should fail if "foo/bar" doesn't exist but
           // succeed if "foo/bar/qux/baz" doesn't exist.
-          if (error.osError.errorCode == _enoent ||
-              error.osError.errorCode == _enoentWin) {
+          if (error.osError!.errorCode == _enoent ||
+              error.osError!.errorCode == _enoentWin) {
             return const [];
           } else {
             rethrow;
@@ -445,10 +471,10 @@
   /// This ensures that listing "foo/bar/*" fails on case-sensitive systems if
   /// "foo/bar" doesn't exist.
   void _validateIntermediateChildrenSync(
-      String dir, List<FileSystemEntity> entities) {
+      String dir, List<FileSystemEntity> entities, FileSystem fileSystem) {
     if (_caseSensitive) return;
 
-    children.forEach((sequence, child) {
+    children!.forEach((sequence, child) {
       if (!child._isIntermediate) return;
       if (entities.any(
           (entity) => sequence.matches(p.relative(entity.path, from: dir)))) {
@@ -459,15 +485,14 @@
       // 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));
+      child.listSync(
+          p.join(dir, (sequence.nodes.single as LiteralNode).text), fileSystem);
     });
   }
 
   /// Returns whether the native [path] matches [_validator].
-  bool _matches(String path) {
-    if (_validator == null) return false;
-    return _validator.matches(toPosixPath(p.context, path));
-  }
+  bool _matches(String path) =>
+      _validator?.matches(toPosixPath(p.context, path)) ?? false;
 
   @override
   String toString() => '($_validator) $children';
diff --git a/lib/src/parser.dart b/lib/src/parser.dart
index 20ade31..e76f7c5 100644
--- a/lib/src/parser.dart
+++ b/lib/src/parser.dart
@@ -69,7 +69,7 @@
   /// Tries to parse a [StarNode] or a [DoubleStarNode].
   ///
   /// Returns `null` if there's not one to parse.
-  AstNode _parseStar() {
+  AstNode? _parseStar() {
     if (!_scanner.scan('*')) return null;
     return _scanner.scan('*')
         ? DoubleStarNode(_context, caseSensitive: _caseSensitive)
@@ -79,7 +79,7 @@
   /// Tries to parse an [AnyCharNode].
   ///
   /// Returns `null` if there's not one to parse.
-  AstNode _parseAnyChar() {
+  AstNode? _parseAnyChar() {
     if (!_scanner.scan('?')) return null;
     return AnyCharNode(caseSensitive: _caseSensitive);
   }
@@ -87,7 +87,7 @@
   /// Tries to parse an [RangeNode].
   ///
   /// Returns `null` if there's not one to parse.
-  AstNode _parseRange() {
+  AstNode? _parseRange() {
     if (!_scanner.scan('[')) return null;
     if (_scanner.matches(']')) _scanner.error('unexpected "]".');
     var negated = _scanner.scan('!') || _scanner.scan('^');
@@ -134,7 +134,7 @@
   /// Tries to parse an [OptionsNode].
   ///
   /// Returns `null` if there's not one to parse.
-  AstNode _parseOptions() {
+  AstNode? _parseOptions() {
     if (!_scanner.scan('{')) return null;
     if (_scanner.matches('}')) _scanner.error('unexpected "}".');
 
@@ -157,12 +157,12 @@
     var regExp = RegExp(inOptions ? r'[^*{[?\\}\],()]*' : r'[^*{[?\\}\]()]*');
 
     _scanner.scan(regExp);
-    var buffer = StringBuffer()..write(_scanner.lastMatch[0]);
+    var buffer = StringBuffer()..write(_scanner.lastMatch![0]);
 
     while (_scanner.scan('\\')) {
       buffer.writeCharCode(_scanner.readChar());
       _scanner.scan(regExp);
-      buffer.write(_scanner.lastMatch[0]);
+      buffer.write(_scanner.lastMatch![0]);
     }
 
     for (var char in const [']', '(', ')']) {
diff --git a/pubspec.yaml b/pubspec.yaml
index 7454ccd..54bef3f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,21 +1,30 @@
 name: glob
-version: 1.2.1-dev
+version: 2.0.0-nullsafety.dev
 
 description: Bash-style filename globbing.
 author: Dart Team <misc@dartlang.org>
 homepage: https://github.com/dart-lang/glob
 
+# Can't be published until we confirm we can land this change internally and in
+# the sdk.
+publish_to: none
+
 environment:
-  sdk: '>=2.2.0 <3.0.0'
+  sdk: '>=2.12.0-0 <3.0.0'
 
 dependencies:
-  async: '>=1.2.0 <3.0.0'
-  collection: ^1.1.0
-  node_io: ^1.0.0
-  path: ^1.3.0
-  pedantic: ^1.2.0
-  string_scanner: '>=0.1.0 <2.0.0'
+  async: ^2.5.0-nullsafety
+  collection: ^1.15.0-nullsafety
+  file: ^6.0.0-nullsafety
+  path: ^1.8.0-nullsafety
+  pedantic: ^1.10.0-nullsafety
+  string_scanner: ^1.1.0-nullsafety
 
 dev_dependencies:
-  test: ^1.6.0
-  test_descriptor: ^1.0.0
+  test: ^1.16.0-nullsafety.9
+  test_descriptor: ^2.0.0-nullsafety
+
+# Required to get a version solve due to cyclic deps
+dependency_overrides:
+  analyzer: ">=0.36.0 <0.41.0"
+  test_core: ^0.3.12-nullsafety.9
diff --git a/test/glob_test.dart b/test/glob_test.dart
index 281a413..85c3fac 100644
--- a/test/glob_test.dart
+++ b/test/glob_test.dart
@@ -63,7 +63,7 @@
 
   group('GlobMatch', () {
     var glob = Glob('foo*');
-    var match = glob.matchAsPrefix('foobar');
+    var match = glob.matchAsPrefix('foobar')!;
 
     test('returns the string as input', () {
       expect(match.input, equals('foobar'));
diff --git a/test/list_test.dart b/test/list_test.dart
index 5fac113..512d901 100644
--- a/test/list_test.dart
+++ b/test/list_test.dart
@@ -7,6 +7,7 @@
 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';
@@ -314,7 +315,7 @@
 }
 
 typedef ListFn = FutureOr<List<String>> Function(String glob,
-    {bool recursive, bool followLinks, bool caseSensitive});
+    {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].