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/pkgs/glob/lib/glob.dart b/pkgs/glob/lib/glob.dart index 1c7efda..f80faa1 100644 --- a/pkgs/glob/lib/glob.dart +++ b/pkgs/glob/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/pkgs/glob/lib/src/ast.dart b/pkgs/glob/lib/src/ast.dart index 5e1da1a..380aa51 100644 --- a/pkgs/glob/lib/src/ast.dart +++ b/pkgs/glob/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/pkgs/glob/lib/src/list_tree.dart b/pkgs/glob/lib/src/list_tree.dart new file mode 100644 index 0000000..fea2b98 --- /dev/null +++ b/pkgs/glob/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/pkgs/glob/lib/src/stream_pool.dart b/pkgs/glob/lib/src/stream_pool.dart new file mode 100644 index 0000000..6417513 --- /dev/null +++ b/pkgs/glob/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/pkgs/glob/lib/src/utils.dart b/pkgs/glob/lib/src/utils.dart index 12952e8..40ac5a6 100644 --- a/pkgs/glob/lib/src/utils.dart +++ b/pkgs/glob/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/pkgs/glob/pubspec.yaml b/pkgs/glob/pubspec.yaml index 2a5e9a8..fdcdcfa 100644 --- a/pkgs/glob/pubspec.yaml +++ b/pkgs/glob/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/pkgs/glob/test/list_test.dart b/pkgs/glob/test/list_test.dart new file mode 100644 index 0000000..676d6f0 --- /dev/null +++ b/pkgs/glob/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/pkgs/glob/test/match_test.dart b/pkgs/glob/test/match_test.dart index ad9dcf3..448d60c 100644 --- a/pkgs/glob/test/match_test.dart +++ b/pkgs/glob/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))); });