Add support for listing to the glob package.

R=rnystrom@google.com
BUG=17093

Review URL: https://codereview.chromium.org//549633002

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart@40604 260f80e4-7a28-3924-810f-c04153c831b5
diff --git a/lib/glob.dart b/lib/glob.dart
index 1c7efda..f80faa1 100644
--- a/lib/glob.dart
+++ b/lib/glob.dart
@@ -4,16 +4,19 @@
 
 library glob;
 
+import 'dart:async';
+import 'dart:io';
+
 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 = new RegExp(r'[*{[?\\}\],\-()]');
 
-// TODO(nweiz): Add [list] and [listSync] methods.
 /// A glob for matching and listing files and directories.
 ///
 /// A glob matches an entire string as a path. Although the glob pattern uses
@@ -45,6 +48,8 @@
   /// The parsed AST of the glob.
   final AstNode _ast;
 
+  ListTree _listTree;
+
   /// Whether [context]'s current directory is absolute.
   bool get _contextIsAbsolute {
     if (_contextIsAbsoluteCache == null) {
@@ -98,6 +103,47 @@
         _ast = new Parser(pattern + (recursive ? "{,/**}" : ""), context)
             .parse();
 
+  /// Lists all [FileSystemEntity]s beneath [root] that match the glob.
+  ///
+  /// 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> list({String root, bool followLinks: true}) {
+    if (context.style != p.style) {
+      throw new StateError("Can't list glob \"$this\"; it matches "
+          "${context.style} paths, but this platform uses ${p.style} paths.");
+    }
+
+    if (_listTree == null) _listTree = new ListTree(_ast);
+    return _listTree.list(root: root, followLinks: followLinks);
+  }
+
+  /// Synchronously lists all [FileSystemEntity]s beneath [root] that match the
+  /// glob.
+  ///
+  /// 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> listSync({String root, bool followLinks: true}) {
+    if (context.style != p.style) {
+      throw new StateError("Can't list glob \"$this\"; it matches "
+          "${context.style} paths, but this platform uses ${p.style} paths.");
+    }
+
+    if (_listTree == null) _listTree = new ListTree(_ast);
+    return _listTree.listSync(root: root, followLinks: followLinks);
+  }
+
   /// Returns whether this glob matches [path].
   bool matches(String path) => matchAsPrefix(path) != null;
 
diff --git a/lib/src/ast.dart b/lib/src/ast.dart
index 5e1da1a..380aa51 100644
--- a/lib/src/ast.dart
+++ b/lib/src/ast.dart
@@ -5,6 +5,7 @@
 library glob.ast;
 
 import 'package:path/path.dart' as p;
+import 'package:collection/collection.dart';
 
 import 'utils.dart';
 
@@ -25,6 +26,17 @@
   /// Either this or [canMatchRelative] or both will be true.
   final bool canMatchRelative = true;
 
+  /// 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() => new OptionsNode([new SequenceNode([this])]);
+
   /// Returns whether this glob matches [string].
   bool matches(String string) {
     if (_regExp == null) _regExp = new RegExp('^${_toRegExp()}\$');
@@ -46,8 +58,118 @@
   SequenceNode(Iterable<AstNode> nodes)
       : nodes = nodes.toList();
 
+  OptionsNode flattenOptions() {
+    if (nodes.isEmpty) return new OptionsNode([this]);
+
+    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 new OptionsNode(sequences.map((sequence) {
+      // Combine any adjacent LiteralNodes in [sequence].
+      return new SequenceNode(sequence.fold([], (combined, node) {
+        if (combined.isEmpty || combined.last is! LiteralNode ||
+            node is! LiteralNode) {
+          return combined..add(node);
+        }
+
+        combined[combined.length - 1] =
+            new LiteralNode(combined.last.text + node.text);
+        return combined;
+      }));
+    }));
+  }
+
+  /// 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 = [];
+    var currentComponent;
+
+    addNode(node) {
+      if (currentComponent == null) currentComponent = [];
+      currentComponent.add(node);
+    }
+
+    finishComponent() {
+      if (currentComponent == null) return;
+      componentsToReturn.add(new SequenceNode(currentComponent));
+      currentComponent = null;
+    }
+
+    for (var node in nodes) {
+      if (node is! LiteralNode || !node.text.contains('/')) {
+        addNode(node);
+        continue;
+      }
+
+      var text = node.text;
+      if (context.style == p.Style.windows) text = text.replaceAll("/", "\\");
+      var 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(new LiteralNode(root));
+        }
+        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(new LiteralNode(component));
+        finishComponent();
+      }
+
+      // For the final component, only end its sequence (by adding a new empty
+      // sequence) if it ends with a separator.
+      addNode(new LiteralNode(components.last));
+      if (node.text.endsWith('/')) finishComponent();
+    }
+
+    finishComponent();
+    return componentsToReturn;
+  }
+
   String _toRegExp() => nodes.map((node) => node._toRegExp()).join();
 
+  bool operator==(Object other) => other is SequenceNode &&
+      const IterableEquality().equals(nodes, other.nodes);
+
+  int get hashCode => const IterableEquality().hash(nodes);
+
   String toString() => nodes.join();
 }
 
@@ -57,6 +179,10 @@
 
   String _toRegExp() => '[^/]*';
 
+  bool operator==(Object other) => other is StarNode;
+
+  int get hashCode => 0;
+
   String toString() => '*';
 }
 
@@ -94,6 +220,10 @@
     return buffer.toString();
   }
 
+  bool operator==(Object other) => other is DoubleStarNode;
+
+  int get hashCode => 1;
+
   String toString() => '**';
 }
 
@@ -103,6 +233,10 @@
 
   String _toRegExp() => '[^/]';
 
+  bool operator==(Object other) => other is AnyCharNode;
+
+  int get hashCode => 2;
+
   String toString() => '?';
 }
 
@@ -119,6 +253,20 @@
   RangeNode(Iterable<Range> ranges, {this.negated})
       : ranges = ranges.toSet();
 
+  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 new OptionsNode(ranges.map((range) {
+      return new SequenceNode([
+        new LiteralNode(new String.fromCharCodes([range.min]))
+      ]);
+    }));
+  }
+
   String _toRegExp() {
     var buffer = new StringBuffer();
 
@@ -148,6 +296,14 @@
     return buffer.toString();
   }
 
+  bool operator==(Object other) {
+    if (other is! RangeNode) return false;
+    if (other.negated != negated) return false;
+    return const SetEquality().equals(ranges, other.ranges);
+  }
+
+  int get hashCode => (negated ? 1 : 3) * const SetEquality().hash(ranges);
+
   String toString() {
     var buffer = new StringBuffer()..write('[');
     for (var range in ranges) {
@@ -172,9 +328,17 @@
   OptionsNode(Iterable<SequenceNode> options)
       : options = options.toList();
 
+  OptionsNode flattenOptions() => new OptionsNode(
+      options.expand((option) => option.flattenOptions().options));
+
   String _toRegExp() =>
       '(?:${options.map((option) => option._toRegExp()).join("|")})';
 
+  bool operator==(Object other) => other is OptionsNode && 
+      const UnorderedIterableEquality().equals(options, other.options);
+
+  int get hashCode => const UnorderedIterableEquality().hash(options);
+
   String toString() => '{${options.join(',')}}';
 }
 
@@ -196,9 +360,13 @@
 
   bool get canMatchRelative => !canMatchAbsolute;
 
-  LiteralNode(this.text, this._context);
+  LiteralNode(this.text, [this._context]);
 
   String _toRegExp() => regExpQuote(text);
 
+  bool operator==(Object other) => other is LiteralNode && other.text == text;
+
+  int get hashCode => text.hashCode;
+
   String toString() => text;
 }
diff --git a/lib/src/list_tree.dart b/lib/src/list_tree.dart
new file mode 100644
index 0000000..fea2b98
--- /dev/null
+++ b/lib/src/list_tree.dart
@@ -0,0 +1,390 @@
+// 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.
+
+library glob.list_tree;
+
+import 'dart:io';
+import 'dart:async';
+
+import 'package:path/path.dart' as p;
+
+import 'ast.dart';
+import 'stream_pool.dart';
+
+/// 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 _trees = new Map<String, _ListTreeNode>();
+
+  /// Whether paths listed might overlap.
+  ///
+  /// If they do, we need to filter out overlapping paths.
+  bool _canOverlap;
+
+  ListTree(AstNode glob) {
+    // 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();
+
+    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;
+        if (Platform.isWindows) 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);
+    }
+
+    _canOverlap = _computeCanOverlap();
+  }
+
+  /// Add the glob represented by [components] to the tree under [root].
+  void _addGlob(String root, List<AstNode> components) {
+    // 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.
+          if (!parent.children.containsKey(component)) {
+            parent.children[component] = new _ListTreeNode();
+          }
+          parent = parent.children[component];
+        }
+      } else if (recursive) {
+        _trees[root] = new _ListTreeNode.recursive(
+            _join(components.sublist(i)));
+        return;
+      } else if (complete) {
+        _trees[root] = new _ListTreeNode()..addOption(component);
+      } else {
+        _trees[root] = new _ListTreeNode();
+        _trees[root].children[component] = new _ListTreeNode();
+        parent = _trees[root].children[component];
+      }
+    }
+  }
+
+  /// Computes the value for [_canOverlap].
+  bool _computeCanOverlap() {
+    // 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}) {
+    if (root == null) root = '.';
+    var pool = new StreamPool();
+    for (var rootDir in _trees.keys) {
+      var dir = rootDir == '.' ? root : rootDir;
+      pool.add(_trees[rootDir].list(dir, followLinks: followLinks));
+    }
+    pool.closeWhenEmpty();
+
+    if (!_canOverlap) return pool.stream;
+
+    // TODO(nweiz): Rather than filtering here, avoid double-listing directories
+    // in the first place.
+    var seen = new Set();
+    return pool.stream.where((entity) {
+      if (seen.contains(entity.path)) return false;
+      seen.add(entity.path);
+      return true;
+    });
+  }
+
+  /// Synchronosuly list all entities that match this glob beneath [root].
+  List<FileSystemEntity> listSync({String root, bool followLinks: true}) {
+    if (root == null) root = '.';
+
+    var result = _trees.keys.expand((rootDir) {
+      var dir = rootDir == '.' ? root : rootDir;
+      return _trees[rootDir].listSync(dir, followLinks: followLinks);
+    });
+
+    if (!_canOverlap) return result.toList();
+
+    // TODO(nweiz): Rather than filtering here, avoid double-listing directories
+    // in the first place.
+    var seen = new Set();
+    return result.where((entity) {
+      if (seen.contains(entity.path)) return false;
+      seen.add(entity.path);
+      return true;
+    }).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;
+
+  /// 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 && 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 = new Map<SequenceNode, _ListTreeNode>(),
+        _validator = null;
+
+  /// Creates a recursive node the given [validator].
+  _ListTreeNode.recursive(SequenceNode validator)
+      : children = null,
+        _validator = new OptionsNode([validator]);
+
+  /// Transforms this into recursive node, folding all its children into its
+  /// validator.
+  void makeRecursive() {
+    if (isRecursive) return;
+    _validator = new OptionsNode(children.keys.map((sequence) {
+      var child = children[sequence];
+      child.makeRecursive();
+      return _join([sequence, child._validator]);
+    }));
+    children = null;
+  }
+
+  /// Adds [validator] to this node's existing validator.
+  void addOption(SequenceNode validator) {
+    if (_validator == null) {
+      _validator = new OptionsNode([validator]);
+    } 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, {bool followLinks: true}) {
+    if (isRecursive) {
+      return new Directory(dir).list(recursive: true, followLinks: followLinks)
+          .where((entity) => _matches(entity.path.substring(dir.length + 1)));
+    }
+
+    var resultPool = new StreamPool();
+
+    // Don't spawn extra [Directory.list] calls when we already know exactly
+    // which subdirectories we're interested in.
+    if (_isIntermediate) {
+      children.forEach((sequence, child) {
+        resultPool.add(child.list(p.join(dir, sequence.nodes.single.text),
+            followLinks: followLinks));
+      });
+      resultPool.closeWhenEmpty();
+      return resultPool.stream;
+    }
+
+    var resultController = new StreamController(sync: true);
+    resultPool.add(resultController.stream);
+    new Directory(dir).list(followLinks: followLinks).listen((entity) {
+      var basename = entity.path.substring(dir.length + 1);
+      if (_matches(basename)) resultController.add(entity);
+
+      children.forEach((sequence, child) {
+        if (entity is! Directory) return;
+        if (!sequence.matches(basename)) return;
+        resultPool.add(child.list(p.join(dir, basename),
+            followLinks: followLinks));
+      });
+    },
+        onError: resultController.addError,
+        onDone: resultController.close);
+
+    resultPool.closeWhenEmpty();
+    return resultPool.stream;
+  }
+
+  /// 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, {bool followLinks: true}) {
+    if (isRecursive) {
+      return new Directory(dir)
+          .listSync(recursive: true, followLinks: followLinks)
+          .where((entity) => _matches(entity.path.substring(dir.length + 1)));
+    }
+
+    // Don't spawn extra [Directory.listSync] calls when we already know exactly
+    // which subdirectories we're interested in.
+    if (_isIntermediate) {
+      return children.keys.expand((sequence) {
+        return children[sequence].listSync(
+            p.join(dir, sequence.nodes.single.text), followLinks: followLinks);
+      });
+    }
+
+    return new Directory(dir).listSync(followLinks: followLinks)
+        .expand((entity) {
+      var entities = [];
+      var basename = entity.path.substring(dir.length + 1);
+      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), followLinks: followLinks);
+      }));
+
+      return entities;
+    });
+  }
+
+  /// Returns whether the native [path] matches [_validator].
+  bool _matches(String path) {
+    if (_validator == null) return false;
+    return _validator.matches(path);
+  }
+
+  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 nodes = [componentsList.removeAt(0)];
+  for (var component in componentsList) {
+    nodes.add(new LiteralNode('/'));
+    nodes.add(component);
+  }
+  return new SequenceNode(nodes);
+}
diff --git a/lib/src/stream_pool.dart b/lib/src/stream_pool.dart
new file mode 100644
index 0000000..6417513
--- /dev/null
+++ b/lib/src/stream_pool.dart
@@ -0,0 +1,76 @@
+// 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.
+
+library glob.stream_pool;
+
+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 = new Map<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 = new 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 = new 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/lib/src/utils.dart b/lib/src/utils.dart
index 12952e8..40ac5a6 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -4,6 +4,8 @@
 
 library glob.utils;
 
+import 'package:path/path.dart' as p;
+
 /// A range from [min] to [max], inclusive.
 class Range {
   /// The minimum value included by the range.
@@ -23,6 +25,11 @@
 
   /// Whether [this] contains [value].
   bool contains(int value) => value >= min && value <= max;
+
+  bool operator==(Object other) => other is Range &&
+      other.min == min && other.max == max;
+
+  int get hashCode => 3 * min + 7 * max;
 }
 
 /// An implementation of [Match] constructed by [Glob]s.
@@ -53,3 +60,11 @@
 /// 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.context != p.Style.windows) return path;
+  return path.replaceAll('\\', '/');
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 2a5e9a8..fdcdcfa 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,8 +1,9 @@
 name: glob
-version: 1.0.0-dev
+version: 1.0.0
 description: Bash-style filename globbing.
 dependencies:
   path: ">=1.0.0 <2.0.0"
   string_scanner: ">=0.1.0 <0.2.0"
 dev_dependencies:
   unittest: ">=0.11.0 <0.12.0"
+  scheduled_test: ">=0.11.2 <0.12.0"
diff --git a/test/list_test.dart b/test/list_test.dart
new file mode 100644
index 0000000..676d6f0
--- /dev/null
+++ b/test/list_test.dart
@@ -0,0 +1,272 @@
+// 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 'dart:io';
+
+import 'package:glob/glob.dart';
+import 'package:glob/src/utils.dart';
+import 'package:path/path.dart' as p;
+import 'package:scheduled_test/descriptor.dart' as d;
+import 'package:scheduled_test/scheduled_test.dart';
+
+String sandbox;
+
+void main() {
+  setUp(() {
+    scheduleSandbox();
+
+    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(new Glob("*", context: p.url).list, throwsStateError);
+    });
+
+    test("reports exceptions for non-existent directories", () {
+      schedule(() {
+        expect(new Glob("non/existent/**").list().toList(),
+            throwsA(new isInstanceOf<FileSystemException>()));
+      });
+    });
+  });
+
+  group("listSync()", () {
+    test("fails if the context doesn't match the system context", () {
+      expect(new Glob("*", context: p.url).listSync, throwsStateError);
+    });
+
+    test("reports exceptions for non-existent directories", () {
+      schedule(() {
+        expect(new Glob("non/existent/**").listSync,
+            throwsA(new isInstanceOf<FileSystemException>()));
+      });
+    });
+  });
+
+  syncAndAsync((list) {
+    group("literals", () {
+      test("lists a single literal", () {
+        expect(list("foo/baz/qux"), completion(equals(["foo/baz/qux"])));
+      });
+
+      test("lists a non-matching literal", () {
+        expect(list("foo/baz/nothing"), completion(isEmpty));
+      });
+    });
+
+    group("star", () {
+      test("lists within filenames but not across directories", () {
+        expect(list("foo/b*"),
+            completion(unorderedEquals(["foo/bar", "foo/baz"])));
+      });
+
+      test("lists the empy string", () {
+        expect(list("foo/bar*"), completion(equals(["foo/bar"])));
+      });
+    });
+
+    group("double star", () {
+      test("lists within filenames", () {
+        expect(list("foo/baz/**"),
+            completion(unorderedEquals(["foo/baz/qux", "foo/baz/bang"])));
+      });
+
+      test("lists the empty string", () {
+        expect(list("foo/bar**"), completion(equals(["foo/bar"])));
+      });
+
+      test("lists recursively", () {
+        expect(list("foo/**"), completion(unorderedEquals(
+            ["foo/bar", "foo/baz", "foo/baz/qux", "foo/baz/bang"])));
+      });
+
+      test("combines with literals", () {
+        expect(list("foo/ba**"), completion(unorderedEquals(
+            ["foo/bar", "foo/baz", "foo/baz/qux", "foo/baz/bang"])));
+      });
+
+      test("lists recursively in the middle of a glob", () {
+        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(list("deep/**/?/?"),
+            completion(unorderedEquals(["deep/a/b/c", "deep/a/b/c/d"])));
+      });
+    });
+
+    group("any char", () {
+      test("matches a character", () {
+        expect(list("foo/ba?"),
+            completion(unorderedEquals(["foo/bar", "foo/baz"])));
+      });
+
+      test("doesn't match a separator", () {
+        expect(list("foo?bar"), completion(isEmpty));
+      });
+    });
+
+    group("range", () {
+      test("matches a range of characters", () {
+        expect(list("foo/ba[a-z]"),
+            completion(unorderedEquals(["foo/bar", "foo/baz"])));
+      });
+
+      test("matches a specific list of characters", () {
+        expect(list("foo/ba[rz]"),
+            completion(unorderedEquals(["foo/bar", "foo/baz"])));
+      });
+
+      test("doesn't match outside its range", () {
+        expect(list("foo/ba[a-x]"),
+            completion(unorderedEquals(["foo/bar"])));
+      });
+
+      test("doesn't match outside its specific list", () {
+        expect(list("foo/ba[rx]"),
+            completion(unorderedEquals(["foo/bar"])));
+      });
+    });
+
+    test("the same file shouldn't be non-recursively listed multiple times",
+        () {
+      d.dir("multi", [
+        d.dir("start-end", [d.file("file")])
+      ]).create();
+
+      expect(list("multi/{start-*/f*,*-end/*e}"),
+          completion(equals(["multi/start-end/file"])));
+    });
+
+    test("the same file shouldn't be recursively listed multiple times", () {
+      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(list("multi/{*/*/*/file,a/**/file}"), completion(unorderedEquals([
+        "multi/a/b/file", "multi/a/b/c/file", "multi/a/x/y/file"
+      ])));
+    });
+
+    group("with symlinks", () {
+      setUp(() {
+        schedule(() {
+          return new Link(p.join(sandbox, "dir", "link"))
+              .create(p.join(sandbox, "foo", "baz"), recursive: true);
+        }, "symlink foo/baz to dir/link");
+      });
+
+      test("follows symlinks by default", () {
+        expect(list("dir/**"), completion(unorderedEquals([
+          "dir/link", "dir/link/bang", "dir/link/qux"
+        ])));
+      });
+
+      test("doesn't follow symlinks with followLinks: false", () {
+        expect(list("dir/**", followLinks: false),
+            completion(equals(["dir/link"])));
+      });
+
+      test("shouldn't crash on broken symlinks", () {
+        schedule(() {
+          return new Directory(p.join(sandbox, "foo")).delete(recursive: true);
+        });
+
+        expect(list("dir/**"), completion(equals(["dir/link"])));
+      });
+    });
+
+    test("always lists recursively with recursive: true", () {
+      expect(list("foo", recursive: true), completion(unorderedEquals(
+          ["foo", "foo/bar", "foo/baz", "foo/baz/qux", "foo/baz/bang"])));
+    });
+
+    test("lists an absolute glob", () {
+      expect(schedule(() {
+        var pattern = separatorToForwardSlash(
+            p.absolute(p.join(sandbox, 'foo/baz/**')));
+
+        return list(pattern);
+      }), completion(unorderedEquals(["foo/baz/bang", "foo/baz/qux"])));
+    });
+  });
+}
+
+typedef Future<List<String>> ListFn(String glob,
+    {bool recursive, bool followLinks});
+
+/// Runs [callback] in two groups with two values of [listFn]: one that uses
+/// [Glob.list], one that uses [Glob.listSync].
+void syncAndAsync(callback(ListFn listFn)) {
+  group("async", () {
+    callback((glob, {recursive: false, followLinks: true}) {
+      return schedule(() {
+        return new Glob(glob, recursive: recursive)
+            .list(root: sandbox, followLinks: followLinks)
+            .map((entity) {
+          return separatorToForwardSlash(
+              p.relative(entity.path, from: sandbox));
+        }).toList();
+      }, 'listing $glob');
+    });
+  });
+
+  group("sync", () {
+    callback((glob, {recursive: false, followLinks: true}) {
+      return schedule(() {
+        return new Glob(glob, recursive: recursive)
+            .listSync(root: sandbox, followLinks: followLinks)
+            .map((entity) {
+          return separatorToForwardSlash(
+              p.relative(entity.path, from: sandbox));
+        }).toList();
+      }, 'listing $glob');
+    });
+  });
+}
+
+void scheduleSandbox() {
+  schedule(() {
+    return Directory.systemTemp.createTemp('glob_').then((dir) {
+      sandbox = dir.path;
+      d.defaultRoot = sandbox;
+    });
+  }, 'creating sandbox');
+
+  currentSchedule.onComplete.schedule(() {
+    d.defaultRoot = null;
+    if (sandbox == null) return null;
+    var oldSandbox = sandbox;
+    sandbox = null;
+    return new Directory(oldSandbox).delete(recursive: true);
+  });
+}
diff --git a/test/match_test.dart b/test/match_test.dart
index ad9dcf3..448d60c 100644
--- a/test/match_test.dart
+++ b/test/match_test.dart
@@ -3,6 +3,7 @@
 // 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:unittest/unittest.dart';
 
@@ -234,8 +235,7 @@
   });
 
   test("a relative path can be matched by an absolute glob", () {
-    var pattern = p.absolute('foo/bar');
-    if (p.style == p.Style.windows) pattern = pattern.replaceAll('\\', '/');
+    var pattern = separatorToForwardSlash(p.absolute('foo/bar'));
     expect('foo/bar', contains(new Glob(pattern)));
   });