Make Windows path manipulation case-insensitive. (#15)

See #14
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a404f70..3c9fb6b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 ## 1.3.10
 
+* Properly compare Windows paths case-insensitively.
+
 * Further improve the performance of `isWithin()`.
 
 ## 1.3.9
diff --git a/lib/src/context.dart b/lib/src/context.dart
index 8819706..3e84cae 100644
--- a/lib/src/context.dart
+++ b/lib/src/context.dart
@@ -444,15 +444,14 @@
     // calculation of relative paths, even if a path has not been normalized.
     if (fromParsed.root != pathParsed.root &&
         ((fromParsed.root == null || pathParsed.root == null) ||
-            fromParsed.root.toLowerCase().replaceAll('/', '\\') !=
-                pathParsed.root.toLowerCase().replaceAll('/', '\\'))) {
+            !style.pathsEqual(fromParsed.root, pathParsed.root))) {
       return pathParsed.toString();
     }
 
     // Strip off their common prefix.
     while (fromParsed.parts.length > 0 &&
         pathParsed.parts.length > 0 &&
-        fromParsed.parts[0] == pathParsed.parts[0]) {
+        style.pathsEqual(fromParsed.parts[0], pathParsed.parts[0])) {
       fromParsed.parts.removeAt(0);
       fromParsed.separators.removeAt(1);
       pathParsed.parts.removeAt(0);
@@ -563,15 +562,7 @@
     for (var i = 0; i < parentRootLength; i++) {
       var parentCodeUnit = parent.codeUnitAt(i);
       var childCodeUnit = child.codeUnitAt(i);
-      if (parentCodeUnit == childCodeUnit) continue;
-
-      // If both code units are separators, that's fine too.
-      //
-      //     isWithin("C:/", r"C:\foo") //=> true
-      if (!style.isSeparator(parentCodeUnit) ||
-          !style.isSeparator(childCodeUnit)) {
-        return false;
-      }
+      if (!style.codeUnitsEqual(parentCodeUnit, childCodeUnit)) return false;
     }
 
     // Start by considering the last code unit as a separator, since
@@ -585,17 +576,7 @@
     while (parentIndex < parent.length && childIndex < child.length) {
       var parentCodeUnit = parent.codeUnitAt(parentIndex);
       var childCodeUnit = child.codeUnitAt(childIndex);
-      if (parentCodeUnit == childCodeUnit) {
-        lastCodeUnit = parentCodeUnit;
-        parentIndex++;
-        childIndex++;
-        continue;
-      }
-
-      // Different separators are considered identical.
-      var parentIsSeparator = style.isSeparator(parentCodeUnit);
-      var childIsSeparator = style.isSeparator(childCodeUnit);
-      if (parentIsSeparator && childIsSeparator) {
+      if (style.codeUnitsEqual(parentCodeUnit, childCodeUnit)) {
         lastCodeUnit = parentCodeUnit;
         parentIndex++;
         childIndex++;
@@ -603,10 +584,12 @@
       }
 
       // Ignore multiple separators in a row.
-      if (parentIsSeparator && style.isSeparator(lastCodeUnit)) {
+      if (style.isSeparator(parentCodeUnit) &&
+          style.isSeparator(lastCodeUnit)) {
         parentIndex++;
         continue;
-      } else if (childIsSeparator && style.isSeparator(lastCodeUnit)) {
+      } else if (style.isSeparator(childCodeUnit) &&
+          style.isSeparator(lastCodeUnit)) {
         childIndex++;
         continue;
       }
diff --git a/lib/src/internal_style.dart b/lib/src/internal_style.dart
index 84bc67e..1450d53 100644
--- a/lib/src/internal_style.dart
+++ b/lib/src/internal_style.dart
@@ -66,4 +66,14 @@
 
   /// Returns the URI that represents [path], which is assumed to be absolute.
   Uri absolutePathToUri(String path);
+
+  /// Returns whether [codeUnit1] and [codeUnit2] are considered equivalent for
+  /// this style.
+  bool codeUnitsEqual(int codeUnit1, int codeUnit2) => codeUnit1 == codeUnit2;
+
+  /// Returns whether [path1] and [path2] are equivalent.
+  ///
+  /// This only needs to handle character-by-character comparison; it can assume
+  /// the paths are normalized and contain no `..` components.
+  bool pathsEqual(String path1, String path2) => path1 == path2;
 }
diff --git a/lib/src/style/windows.dart b/lib/src/style/windows.dart
index 8223486..0d89764 100644
--- a/lib/src/style/windows.dart
+++ b/lib/src/style/windows.dart
@@ -7,6 +7,10 @@
 import '../parsed_path.dart';
 import '../utils.dart';
 
+// `0b100000` can be bitwise-ORed with uppercase ASCII letters to get their
+// lowercase equivalents.
+const _asciiCaseBit = 0x20;
+
 /// The style for Windows paths.
 class WindowsStyle extends InternalStyle {
   WindowsStyle();
@@ -120,4 +124,31 @@
       return new Uri(scheme: 'file', pathSegments: parsed.parts);
     }
   }
+
+  bool codeUnitsEqual(int codeUnit1, int codeUnit2) {
+    if (codeUnit1 == codeUnit2) return true;
+
+    /// Forward slashes and backslashes are equivalent on Windows.
+    if (codeUnit1 == chars.SLASH) return codeUnit2 == chars.BACKSLASH;
+    if (codeUnit1 == chars.BACKSLASH) return codeUnit2 == chars.SLASH;
+
+    // If this check fails, the code units are definitely different. If it
+    // succeeds *and* either codeUnit is an ASCII letter, they're equivalent.
+    if (codeUnit1 ^ codeUnit2 != _asciiCaseBit) return false;
+
+    // Now we just need to verify that one of the code units is an ASCII letter.
+    var upperCase1 = codeUnit1 | _asciiCaseBit;
+    return upperCase1 >= chars.LOWER_A && upperCase1 <= chars.LOWER_Z;
+  }
+
+  bool pathsEqual(String path1, String path2) {
+    if (identical(path1, path2)) return true;
+    if (path1.length != path2.length) return false;
+    for (var i = 0; i < path1.length; i++) {
+      if (!codeUnitsEqual(path1.codeUnitAt(i), path2.codeUnitAt(i))) {
+        return false;
+      }
+    }
+    return true;
+  }
 }
diff --git a/test/posix_test.dart b/test/posix_test.dart
index 7c5aaf4..7b55dd3 100644
--- a/test/posix_test.dart
+++ b/test/posix_test.dart
@@ -348,6 +348,11 @@
         expect(context.relative('a/./b/../c.txt'), 'a/c.txt');
       });
 
+      test('is case-sensitive', () {
+        expect(context.relative('/RoOt'), '../../RoOt');
+        expect(context.relative('/rOoT/pAtH/a'), '../../rOoT/pAtH/a');
+      });
+
       // Regression
       test('from root-only path', () {
         expect(context.relative('/', from: '/'), '.');
diff --git a/test/url_test.dart b/test/url_test.dart
index c81893a..4adfb48 100644
--- a/test/url_test.dart
+++ b/test/url_test.dart
@@ -479,6 +479,15 @@
         expect(context.relative('a/./b/../c.txt'), 'a/c.txt');
       });
 
+      test('is case-sensitive', () {
+        expect(context.relative('HtTp://dartlang.org/root'),
+            'HtTp://dartlang.org/root');
+        expect(context.relative('http://DaRtLaNg.OrG/root'),
+            'http://DaRtLaNg.OrG/root');
+        expect(context.relative('/RoOt'), '../../RoOt');
+        expect(context.relative('/rOoT/pAtH/a'), '../../rOoT/pAtH/a');
+      });
+
       // Regression
       test('from root-only path', () {
         expect(context.relative('http://dartlang.org',
diff --git a/test/windows_test.dart b/test/windows_test.dart
index 8b83c03..8e72bb1 100644
--- a/test/windows_test.dart
+++ b/test/windows_test.dart
@@ -425,6 +425,12 @@
         expect(context.relative(r'a\.\b\..\c.txt'), r'a\c.txt');
       });
 
+      test('is case-insensitive', () {
+        expect(context.relative(r'c:\'), r'..\..');
+        expect(context.relative(r'c:\RoOt'), r'..');
+        expect(context.relative(r'c:\rOoT\pAtH\a'), r'a');
+      });
+
       // Regression
       test('from root-only path', () {
         expect(context.relative(r'C:\', from: r'C:\'), '.');
@@ -567,6 +573,12 @@
       expect(r.isWithin(r'C:\', r'C:\baz\bang'), isTrue);
       expect(r.isWithin('.', r'C:\baz\bang'), isFalse);
     });
+
+    test('is case-insensitive', () {
+      expect(context.isWithin(r'FoO', r'fOo\bar'), isTrue);
+      expect(context.isWithin(r'C:\', r'c:\foo'), isTrue);
+      expect(context.isWithin(r'fOo\qux\..\BaR', r'FoO\bAr\baz'), isTrue);
+    });
   });
 
   group('absolute', () {