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)));
});