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