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