Add a caseSensitive flag to new Glob(). Closes #3 R=rnystrom@google.com Review URL: https://codereview.chromium.org//1491003002 .
diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c00bc..eec6f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md
@@ -1,3 +1,13 @@ +## 1.1.0 + +* Add a `caseSensitive` named parameter to `new Glob()` that controls whether + the glob is case-sensitive. This defaults to `false` on Windows and `true` + elsewhere. + + Matching case-insensitively on Windows is a behavioral change, but since it + more closely matches the semantics of Windows paths it's considered a bug fix + rather than a breaking change. + ## 1.0.5 * Narrow the dependency on `path`. Previously, this allowed versions that didn't
diff --git a/README.md b/README.md index 5091a1e..300e73e 100644 --- a/README.md +++ b/README.md
@@ -50,6 +50,9 @@ regardless of which platform they're on. This is true even for Windows roots; for example, a glob matching all files in the C drive would be `C:/*`. +Globs are case-sensitive by default on Posix systems and browsers, and +case-insensitive by default on Windows. + ### Match any characters in a filename: `*` The `*` character matches zero or more of any character other than `/`. This
diff --git a/lib/glob.dart b/lib/glob.dart index 82c8a12..ca83969 100644 --- a/lib/glob.dart +++ b/lib/glob.dart
@@ -43,6 +43,9 @@ /// contained within a directory that matches. final bool recursive; + /// Whether the glob matches paths case-sensitively. + bool get caseSensitive => _ast.caseSensitive; + /// The parsed AST of the glob. final AstNode _ast; @@ -85,21 +88,24 @@ /// Paths matched against the glob are interpreted according to [context]. It /// defaults to the system context. /// - /// If [recursive] is true, this glob will match and list not only the files - /// and directories it explicitly lists, but anything beneath those as well. - Glob(String pattern, {p.Context context, bool recursive: false}) - : this._( - pattern, - context == null ? p.context : context, - recursive); + /// If [recursive] is true, this glob matches and lists not only the files and + /// directories it explicitly matches, but anything beneath those as well. + /// + /// If [caseSensitive] is true, this glob matches and lists only files whose + /// case matches that of the characters in the glob. Otherwise, it matches + /// regardless of case. This defaults to `false` when [context] is Windows and + /// `true` otherwise. + factory Glob(String pattern, {p.Context context, bool recursive: false, + bool caseSensitive}) { + context ??= p.context; + caseSensitive ??= context.style == p.Style.windows ? false : true; + if (recursive) pattern += "{,/**}"; - // Internal constructor used to fake local variables for [context] and [ast]. - Glob._(String pattern, p.Context context, bool recursive) - : pattern = pattern, - context = context, - recursive = recursive, - _ast = new Parser(pattern + (recursive ? "{,/**}" : ""), context) - .parse(); + var parser = new Parser(pattern, context, caseSensitive: caseSensitive); + return new Glob._(pattern, context, parser.parse(), recursive); + } + + Glob._(this.pattern, this.context, this._ast, this.recursive); /// Lists all [FileSystemEntity]s beneath [root] that match the glob. ///
diff --git a/lib/src/ast.dart b/lib/src/ast.dart index d1cfe63..8582d45 100644 --- a/lib/src/ast.dart +++ b/lib/src/ast.dart
@@ -14,6 +14,9 @@ /// The cached regular expression that this AST was compiled into. RegExp _regExp; + /// Whether this node matches case-sensitively or not. + final bool caseSensitive; + /// Whether this glob could match an absolute path. /// /// Either this or [canMatchRelative] or both will be true. @@ -24,6 +27,8 @@ /// Either this or [canMatchRelative] or both will be true. final bool canMatchRelative = true; + AstNode._(this.caseSensitive); + /// Returns a new glob with all the options bubbled to the top level. /// /// In particular, this returns a glob AST with two guarantees: @@ -33,11 +38,15 @@ /// /// 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])]); + OptionsNode flattenOptions() => new OptionsNode( + [new SequenceNode([this], caseSensitive: caseSensitive)], + caseSensitive: caseSensitive); /// Returns whether this glob matches [string]. bool matches(String string) { - if (_regExp == null) _regExp = new RegExp('^${_toRegExp()}\$'); + if (_regExp == null) { + _regExp = new RegExp('^${_toRegExp()}\$', caseSensitive: caseSensitive); + } return _regExp.hasMatch(string); } @@ -53,11 +62,14 @@ bool get canMatchAbsolute => nodes.first.canMatchAbsolute; bool get canMatchRelative => nodes.first.canMatchRelative; - SequenceNode(Iterable<AstNode> nodes) - : nodes = nodes.toList(); + SequenceNode(Iterable<AstNode> nodes, {bool caseSensitive: true}) + : nodes = nodes.toList(), + super._(caseSensitive); OptionsNode flattenOptions() { - if (nodes.isEmpty) return new OptionsNode([this]); + if (nodes.isEmpty) { + return new OptionsNode([this], caseSensitive: caseSensitive); + } var sequences = nodes.first.flattenOptions().options .map((sequence) => sequence.nodes); @@ -80,11 +92,11 @@ return combined..add(node); } - combined[combined.length - 1] = - new LiteralNode(combined.last.text + node.text); + combined[combined.length - 1] = new LiteralNode( + combined.last.text + node.text, caseSensitive: caseSensitive); return combined; - })); - })); + }), caseSensitive: caseSensitive); + }), caseSensitive: caseSensitive); } /// Splits this glob into components along its path separators. @@ -109,7 +121,8 @@ finishComponent() { if (currentComponent == null) return; - componentsToReturn.add(new SequenceNode(currentComponent)); + componentsToReturn.add( + new SequenceNode(currentComponent, caseSensitive: caseSensitive)); currentComponent = null; } @@ -137,7 +150,7 @@ // So we switch it back here. root = root.replaceAll("\\", "/"); } - addNode(new LiteralNode(root)); + addNode(new LiteralNode(root, caseSensitive: caseSensitive)); } finishComponent(); components = components.skip(1); @@ -147,13 +160,13 @@ // 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)); + addNode(new LiteralNode(component, caseSensitive: caseSensitive)); finishComponent(); } // For the final component, only end its sequence (by adding a new empty // sequence) if it ends with a separator. - addNode(new LiteralNode(components.last)); + addNode(new LiteralNode(components.last, caseSensitive: caseSensitive)); if (node.text.endsWith('/')) finishComponent(); } @@ -173,7 +186,7 @@ /// A node matching zero or more non-separator characters. class StarNode extends AstNode { - StarNode(); + StarNode({bool caseSensitive: true}) : super._(caseSensitive); String _toRegExp() => '[^/]*'; @@ -191,7 +204,8 @@ /// This is used to determine what absolute paths look like. final p.Context _context; - DoubleStarNode(this._context); + DoubleStarNode(this._context, {bool caseSensitive: true}) + : super._(caseSensitive); String _toRegExp() { // Double star shouldn't match paths with a leading "../", since these paths @@ -227,7 +241,7 @@ /// A node matching a single non-separator character. class AnyCharNode extends AstNode { - AnyCharNode(); + AnyCharNode({bool caseSensitive: true}) : super._(caseSensitive); String _toRegExp() => '[^/]'; @@ -248,8 +262,9 @@ /// Whether this range was negated. final bool negated; - RangeNode(Iterable<Range> ranges, {this.negated}) - : ranges = ranges.toSet(); + RangeNode(Iterable<Range> ranges, {this.negated, bool caseSensitive: true}) + : ranges = ranges.toSet(), + super._(caseSensitive); OptionsNode flattenOptions() { if (negated || ranges.any((range) => !range.isSingleton)) { @@ -260,9 +275,10 @@ // a separate expansion. return new OptionsNode(ranges.map((range) { return new SequenceNode([ - new LiteralNode(new String.fromCharCodes([range.min])) - ]); - })); + new LiteralNode(new String.fromCharCodes([range.min]), + caseSensitive: caseSensitive) + ], caseSensitive: caseSensitive); + }), caseSensitive: caseSensitive); } String _toRegExp() { @@ -323,11 +339,13 @@ bool get canMatchAbsolute => options.any((node) => node.canMatchAbsolute); bool get canMatchRelative => options.any((node) => node.canMatchRelative); - OptionsNode(Iterable<SequenceNode> options) - : options = options.toList(); + OptionsNode(Iterable<SequenceNode> options, {bool caseSensitive: true}) + : options = options.toList(), + super._(caseSensitive); OptionsNode flattenOptions() => new OptionsNode( - options.expand((option) => option.flattenOptions().options)); + options.expand((option) => option.flattenOptions().options), + caseSensitive: caseSensitive); String _toRegExp() => '(?:${options.map((option) => option._toRegExp()).join("|")})'; @@ -358,7 +376,9 @@ bool get canMatchRelative => !canMatchAbsolute; - LiteralNode(this.text, [this._context]); + LiteralNode(this.text, {p.Context context, bool caseSensitive: true}) + : _context = context, + super._(caseSensitive); String _toRegExp() => regExpQuote(text);
diff --git a/lib/src/list_tree.dart b/lib/src/list_tree.dart index 779bff5..eff8c7b 100644 --- a/lib/src/list_tree.dart +++ b/lib/src/list_tree.dart
@@ -232,6 +232,13 @@ /// A recursive node has no children and is listed recursively. bool get isRecursive => children == null; + bool get _caseSensitive { + if (_validator != null) return _validator.caseSensitive; + if (children == null) return true; + if (children.isEmpty) return true; + return children.keys.first.caseSensitive; + } + /// Whether this node doesn't itself need to be listed. /// /// If a node has no validator and all of its children are literal filenames, @@ -239,6 +246,7 @@ /// its children. bool get _isIntermediate { if (_validator != null) return false; + if (!_caseSensitive) return false; return children.keys.every((sequence) => sequence.nodes.length == 1 && sequence.nodes.first is LiteralNode); } @@ -253,9 +261,14 @@ // 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) => + if (children.length > 1) { + // Case-insensitivity means that even literals may match multiple entries. + if (!_caseSensitive) return true; + + if (children.keys.any((sequence) => sequence.nodes.length > 1 || sequence.nodes.single is! LiteralNode)) { - return true; + return true; + } } return children.values.any((node) => node.canOverlap); @@ -269,7 +282,8 @@ /// Creates a recursive node the given [validator]. _ListTreeNode.recursive(SequenceNode validator) : children = null, - _validator = new OptionsNode([validator]); + _validator = new OptionsNode([validator], + caseSensitive: validator.caseSensitive); /// Transforms this into recursive node, folding all its children into its /// validator. @@ -279,14 +293,15 @@ var child = children[sequence]; child.makeRecursive(); return _join([sequence, child._validator]); - })); + }), caseSensitive: _caseSensitive); children = null; } /// Adds [validator] to this node's existing validator. void addOption(SequenceNode validator) { if (_validator == null) { - _validator = new OptionsNode([validator]); + _validator = new OptionsNode([validator], + caseSensitive: validator.caseSensitive); } else { _validator.options.add(validator); } @@ -409,10 +424,11 @@ /// a path separator. SequenceNode _join(Iterable<AstNode> components) { var componentsList = components.toList(); - var nodes = [componentsList.removeAt(0)]; + var first = componentsList.removeAt(0); + var nodes = [first]; for (var component in componentsList) { - nodes.add(new LiteralNode('/')); + nodes.add(new LiteralNode('/', caseSensitive: first.caseSensitive)); nodes.add(component); } - return new SequenceNode(nodes); + return new SequenceNode(nodes, caseSensitive: first.caseSensitive); }
diff --git a/lib/src/parser.dart b/lib/src/parser.dart index ab399a5..14cdc19 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart
@@ -19,8 +19,12 @@ /// The path context for the glob. final p.Context _context; - Parser(String component, this._context) - : _scanner = new StringScanner(component); + /// Whether this glob is case-sensitive. + final bool _caseSensitive; + + Parser(String component, this._context, {bool caseSensitive: true}) + : _scanner = new StringScanner(component), + _caseSensitive = caseSensitive; /// Parses an entire glob. SequenceNode parse() => _parseSequence(); @@ -40,7 +44,7 @@ nodes.add(_parseNode(inOptions: inOptions)); } - return new SequenceNode(nodes); + return new SequenceNode(nodes, caseSensitive: _caseSensitive); } /// Parses an [AstNode]. @@ -67,7 +71,9 @@ /// Returns `null` if there's not one to parse. AstNode _parseStar() { if (!_scanner.scan('*')) return null; - return _scanner.scan('*') ? new DoubleStarNode(_context) : new StarNode(); + return _scanner.scan('*') + ? new DoubleStarNode(_context, caseSensitive: _caseSensitive) + : new StarNode(caseSensitive: _caseSensitive); } /// Tries to parse an [AnyCharNode]. @@ -75,7 +81,7 @@ /// Returns `null` if there's not one to parse. AstNode _parseAnyChar() { if (!_scanner.scan('?')) return null; - return new AnyCharNode(); + return new AnyCharNode(caseSensitive: _caseSensitive); } /// Tries to parse an [RangeNode]. @@ -123,7 +129,8 @@ } } - return new RangeNode(ranges, negated: negated); + return new RangeNode(ranges, + negated: negated, caseSensitive: _caseSensitive); } /// Tries to parse an [OptionsNode]. @@ -142,7 +149,7 @@ if (options.length == 1) _scanner.expect(','); _scanner.expect('}'); - return new OptionsNode(options); + return new OptionsNode(options, caseSensitive: _caseSensitive); } /// Parses a [LiteralNode]. @@ -166,6 +173,7 @@ } if (!inOptions && _scanner.matches('}')) _scanner.error('unexpected "}"'); - return new LiteralNode(buffer.toString(), _context); + return new LiteralNode(buffer.toString(), + context: _context, caseSensitive: _caseSensitive); } }
diff --git a/pubspec.yaml b/pubspec.yaml index eb7816c..9ef3b41 100644 --- a/pubspec.yaml +++ b/pubspec.yaml
@@ -1,5 +1,5 @@ name: glob -version: 1.0.5 +version: 1.1.0 author: "Dart Team <misc@dartlang.org>" homepage: https://github.com/dart-lang/glob description: Bash-style filename globbing. @@ -10,3 +10,5 @@ dev_dependencies: test: ">=0.12.0 <0.13.0" scheduled_test: ">=0.12.0 <0.13.0" +environment: + sdk: ">=1.12.0 <2.0.0"
diff --git a/test/glob_test.dart b/test/glob_test.dart index a742f20..f82106a 100644 --- a/test/glob_test.dart +++ b/test/glob_test.dart
@@ -91,4 +91,20 @@ expect(() => match.groups([1]), throwsRangeError); }); }); + + test("globs are case-sensitive by default for Posix and URL contexts", () { + expect("foo", contains(new Glob("foo", context: p.posix))); + expect("FOO", isNot(contains(new Glob("foo", context: p.posix)))); + expect("foo", isNot(contains(new Glob("FOO", context: p.posix)))); + + expect("foo", contains(new Glob("foo", context: p.url))); + expect("FOO", isNot(contains(new Glob("foo", context: p.url)))); + expect("foo", isNot(contains(new Glob("FOO", context: p.url)))); + }); + + test("globs are case-insensitive by default for Windows contexts", () { + expect("foo", contains(new Glob("foo", context: p.windows))); + expect("FOO", contains(new Glob("foo", context: p.windows))); + expect("foo", contains(new Glob("FOO", context: p.windows))); + }); }
diff --git a/test/list_test.dart b/test/list_test.dart index 3327524..948af08 100644 --- a/test/list_test.dart +++ b/test/list_test.dart
@@ -52,6 +52,29 @@ }); }); + group("when case-sensitive", () { + test("lists literals case-sensitively", () { + schedule(() { + expect(new Glob("foo/BAZ/qux", caseSensitive: true).listSync, + throwsA(new isInstanceOf<FileSystemException>())); + }); + }); + + test("lists ranges case-sensitively", () { + schedule(() { + expect(new Glob("foo/[BX][A-Z]z/qux", caseSensitive: true).listSync, + throwsA(new isInstanceOf<FileSystemException>())); + }); + }); + + test("options preserve case-sensitivity", () { + schedule(() { + expect(new Glob("foo/{BAZ,ZAP}/qux", caseSensitive: true).listSync, + throwsA(new isInstanceOf<FileSystemException>())); + }); + }); + }); + syncAndAsync((list) { group("literals", () { test("lists a single literal", () { @@ -263,34 +286,63 @@ expect(list("top/*/subdir/**"), completion(equals([p.join("top", "dir1", "subdir", "file")]))); }); + + group("when case-insensitive", () { + test("lists literals case-insensitively", () { + expect(list("foo/baz/qux", caseSensitive: false), + completion(equals([p.join("foo", "baz", "qux")]))); + expect(list("foo/BAZ/qux", caseSensitive: false), + completion(equals([p.join("foo", "baz", "qux")]))); + }); + + test("lists ranges case-insensitively", () { + expect(list("foo/[bx][a-z]z/qux", caseSensitive: false), + completion(equals([p.join("foo", "baz", "qux")]))); + expect(list("foo/[BX][A-Z]z/qux", caseSensitive: false), + completion(equals([p.join("foo", "baz", "qux")]))); + }); + + test("options preserve case-insensitivity", () { + expect(list("foo/{bar,baz}/qux", caseSensitive: false), + completion(equals([p.join("foo", "baz", "qux")]))); + expect(list("foo/{BAR,BAZ}/qux", caseSensitive: false), + completion(equals([p.join("foo", "baz", "qux")]))); + }); + }); }); } typedef Future<List<String>> ListFn(String glob, - {bool recursive, bool followLinks}); + {bool recursive, bool followLinks, bool caseSensitive}); /// Runs [callback] in two groups with two values of [listFn]: one that uses /// [Glob.list], one that uses [Glob.listSync]. void syncAndAsync(callback(ListFn listFn)) { group("async", () { - callback((glob, {recursive: false, followLinks: true}) { + callback((pattern, {recursive: false, followLinks: true, caseSensitive}) { return schedule(() { - return new Glob(glob, recursive: recursive) + var glob = new Glob(pattern, + recursive: recursive, caseSensitive: caseSensitive); + + return glob .list(root: sandbox, followLinks: followLinks) .map((entity) => p.relative(entity.path, from: sandbox)) .toList(); - }, 'listing $glob'); + }, 'listing $pattern'); }); }); group("sync", () { - callback((glob, {recursive: false, followLinks: true}) { + callback((pattern, {recursive: false, followLinks: true, caseSensitive}) { return schedule(() { - return new Glob(glob, recursive: recursive) + var glob = new Glob(pattern, + recursive: recursive, caseSensitive: caseSensitive); + + return glob .listSync(root: sandbox, followLinks: followLinks) .map((entity) => p.relative(entity.path, from: sandbox)) .toList(); - }, 'listing $glob'); + }, 'listing $pattern'); }); }); }
diff --git a/test/match_test.dart b/test/match_test.dart index 90b5b7f..13dc7d6 100644 --- a/test/match_test.dart +++ b/test/match_test.dart
@@ -291,4 +291,62 @@ expect("/foo/bar", isNot(contains(new Glob("**", context: p.url)))); expect("/foo/bar", contains(new Glob("/**", context: p.url))); }); + + group("when case-sensitive", () { + test("literals match case-sensitively", () { + expect("foo", contains(new Glob("foo", caseSensitive: true))); + expect("FOO", isNot(contains(new Glob("foo", caseSensitive: true)))); + expect("foo", isNot(contains(new Glob("FOO", caseSensitive: true)))); + }); + + test("ranges match case-sensitively", () { + expect("foo", contains(new Glob("[fx][a-z]o", caseSensitive: true))); + expect("FOO", + isNot(contains(new Glob("[fx][a-z]o", caseSensitive: true)))); + expect("foo", + isNot(contains(new Glob("[FX][A-Z]O", caseSensitive: true)))); + }); + + test("sequences preserve case-sensitivity", () { + expect("foo/bar", contains(new Glob("foo/bar", caseSensitive: true))); + expect("FOO/BAR", + isNot(contains(new Glob("foo/bar", caseSensitive: true)))); + expect("foo/bar", + isNot(contains(new Glob("FOO/BAR", caseSensitive: true)))); + }); + + test("options preserve case-sensitivity", () { + expect("foo", contains(new Glob("{foo,bar}", caseSensitive: true))); + expect("FOO", + isNot(contains(new Glob("{foo,bar}", caseSensitive: true)))); + expect("foo", + isNot(contains(new Glob("{FOO,BAR}", caseSensitive: true)))); + }); + }); + + group("when case-insensitive", () { + test("literals match case-insensitively", () { + expect("foo", contains(new Glob("foo", caseSensitive: false))); + expect("FOO", contains(new Glob("foo", caseSensitive: false))); + expect("foo", contains(new Glob("FOO", caseSensitive: false))); + }); + + test("ranges match case-insensitively", () { + expect("foo", contains(new Glob("[fx][a-z]o", caseSensitive: false))); + expect("FOO", contains(new Glob("[fx][a-z]o", caseSensitive: false))); + expect("foo", contains(new Glob("[FX][A-Z]O", caseSensitive: false))); + }); + + test("sequences preserve case-insensitivity", () { + expect("foo/bar", contains(new Glob("foo/bar", caseSensitive: false))); + expect("FOO/BAR", contains(new Glob("foo/bar", caseSensitive: false))); + expect("foo/bar", contains(new Glob("FOO/BAR", caseSensitive: false))); + }); + + test("options preserve case-insensitivity", () { + expect("foo", contains(new Glob("{foo,bar}", caseSensitive: false))); + expect("FOO", contains(new Glob("{foo,bar}", caseSensitive: false))); + expect("foo", contains(new Glob("{FOO,BAR}", caseSensitive: false))); + }); + }); }