Add new equality and canonicalization functions. (#16)

Closes #14
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c9fb6b..2818f7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,7 @@
-## 1.3.10
+## 1.4.0
+
+* Add `equals()`, `hash()` and `canonicalize()` top-level functions and
+  `Context` methods. These make it easier to treat paths as map keys.
 
 * Properly compare Windows paths case-insensitively.
 
diff --git a/lib/path.dart b/lib/path.dart
index 25236ea..59cc520 100644
--- a/lib/path.dart
+++ b/lib/path.dart
@@ -281,9 +281,27 @@
 ///       // -> ['http://dartlang.org', 'path', 'to', 'foo']
 List<String> split(String path) => context.split(path);
 
+/// Canonicalizes [path].
+///
+/// This is guaranteed to return the same path for two different input paths
+/// if and only if both input paths point to the same location. Unlike
+/// [normalize], it returns absolute paths when possible and canonicalizes
+/// ASCII case on Windows.
+///
+/// Note that this does not resolve symlinks.
+///
+/// If you want a map that uses path keys, it's probably more efficient to
+/// pass [equals] and [hash] to [new HashMap] than it is to canonicalize every
+/// key.
+String canonicalize(String path) => context.canonicalize(path);
+
 /// Normalizes [path], simplifying it by handling `..`, and `.`, and
 /// removing redundant path separators whenever possible.
 ///
+/// Note that this is *not* guaranteed to return the same result for two
+/// equivalent input paths. For that, see [canonicalize]. Or, if you're using
+/// paths as map keys, pass [equals] and [hash] to [new HashMap].
+///
 ///     path.normalize('path/./to/..//file.text'); // -> 'path/file.txt'
 String normalize(String path) => context.normalize(path);
 
@@ -324,6 +342,20 @@
 ///     path.isWithin('/root/path', '/root/path') // -> false
 bool isWithin(String parent, String child) => context.isWithin(parent, child);
 
+/// Returns `true` if [path1] points to the same location as [path2], and
+/// `false` otherwise.
+///
+/// The [hash] function returns a hash code that matches these equality
+/// semantics.
+bool equals(String path1, String path2) => context.equals(path1, path2);
+
+/// Returns a hash code for [path] such that, if [equals] returns `true` for two
+/// paths, their hash codes are the same.
+///
+/// Note that the same path may have different hash codes on different platforms
+/// or with different [current] directories.
+int hash(String path) => context.hash(path);
+
 /// Removes a trailing extension from the last part of [path].
 ///
 ///     withoutExtension('path/to/foo.dart'); // -> 'path/to/foo'
diff --git a/lib/src/context.dart b/lib/src/context.dart
index 3e84cae..af97f90 100644
--- a/lib/src/context.dart
+++ b/lib/src/context.dart
@@ -2,6 +2,8 @@
 // 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:math' as math;
+
 import 'characters.dart' as chars;
 import 'internal_style.dart';
 import 'style.dart';
@@ -298,9 +300,34 @@
     return parsed.parts;
   }
 
+  /// Canonicalizes [path].
+  ///
+  /// This is guaranteed to return the same path for two different input paths
+  /// if and only if both input paths point to the same location. Unlike
+  /// [normalize], it returns absolute paths when possible and canonicalizes
+  /// ASCII case on Windows.
+  ///
+  /// Note that this does not resolve symlinks.
+  ///
+  /// If you want a map that uses path keys, it's probably more efficient to
+  /// pass [equals] and [hash] to [new HashMap] than it is to canonicalize every
+  /// key.
+  String canonicalize(String path) {
+    path = absolute(path);
+    if (style != Style.windows && !_needsNormalization(path)) return path;
+
+    var parsed = _parse(path);
+    parsed.normalize(canonicalize: true);
+    return parsed.toString();
+  }
+
   /// Normalizes [path], simplifying it by handling `..`, and `.`, and
   /// removing redundant path separators whenever possible.
   ///
+  /// Note that this is *not* guaranteed to return the same result for two
+  /// equivalent input paths. For that, see [canonicalize]. Or, if you're using
+  /// paths as map keys, pass [equals] and [hash] to [new HashMap].
+  ///
   ///     context.normalize('path/./to/..//file.text'); // -> 'path/file.txt'
   String normalize(String path) {
     if (!_needsNormalization(path)) return path;
@@ -496,7 +523,22 @@
   ///     path.isWithin('/root/path', '/root/path/a'); // -> true
   ///     path.isWithin('/root/path', '/root/other'); // -> false
   ///     path.isWithin('/root/path', '/root/path'); // -> false
-  bool isWithin(String parent, String child) {
+  bool isWithin(String parent, String child) =>
+      _isWithinOrEquals(parent, child) == _PathRelation.within;
+
+  /// Returns `true` if [path1] points to the same location as [path2], and
+  /// `false` otherwise.
+  ///
+  /// The [hash] function returns a hash code that matches these equality
+  /// semantics.
+  bool equals(String path1, String path2) =>
+      _isWithinOrEquals(path1, path2) == _PathRelation.equal;
+
+  /// Compares two paths and returns an enum value indicating their relationship
+  /// to one another.
+  ///
+  /// This never returns [_PathRelation.inconclusive].
+  _PathRelation _isWithinOrEquals(String parent, String child) {
     // Make both paths the same level of relative. We're only able to do the
     // quick comparison if both paths are in the same format, and making a path
     // absolute is faster than making it relative.
@@ -519,8 +561,8 @@
       }
     }
 
-    var fastResult = _isWithinFast(parent, child);
-    if (fastResult != null) return fastResult;
+    var result = _isWithinOrEqualsFast(parent, child);
+    if (result != _PathRelation.inconclusive) return result;
 
     var relative;
     try {
@@ -528,18 +570,22 @@
     } on PathException catch (_) {
       // If no relative path from [parent] to [child] is found, [child]
       // definitely isn't a child of [parent].
-      return false;
+      return _PathRelation.different;
     }
 
-    var parts = this.split(relative);
-    return this.isRelative(relative) &&
-        parts.first != '..' &&
-        parts.first != '.';
+    if (!this.isRelative(relative)) return _PathRelation.different;
+    if (relative == '.') return _PathRelation.equal;
+    if (relative == '..') return _PathRelation.different;
+    return (relative.length >= 3 &&
+            relative.startsWith('..') &&
+             style.isSeparator(relative.codeUnitAt(2)))
+        ? _PathRelation.different
+        : _PathRelation.within;
   }
 
-  /// An optimized implementation of [isWithin] that doesn't handle a few
-  /// complex cases.
-  bool _isWithinFast(String parent, String child) {
+  /// An optimized implementation of [_isWithinOrEquals] that doesn't handle a
+  /// few complex cases.
+  _PathRelation _isWithinOrEqualsFast(String parent, String child) {
     // Normally we just bail when we see "." path components, but we can handle
     // a single dot easily enough.
     if (parent == '.') parent = '';
@@ -553,7 +599,7 @@
     //
     //     isWithin("C:/bar", "//foo/bar/baz") //=> false
     //     isWithin("http://example.com/", "http://google.com/bar") //=> false
-    if (parentRootLength != childRootLength) return false;
+    if (parentRootLength != childRootLength) return _PathRelation.different;
 
     // Make sure that the roots are textually the same as well.
     //
@@ -562,7 +608,9 @@
     for (var i = 0; i < parentRootLength; i++) {
       var parentCodeUnit = parent.codeUnitAt(i);
       var childCodeUnit = child.codeUnitAt(i);
-      if (!style.codeUnitsEqual(parentCodeUnit, childCodeUnit)) return false;
+      if (!style.codeUnitsEqual(parentCodeUnit, childCodeUnit)) {
+        return _PathRelation.different;
+      }
     }
 
     // Start by considering the last code unit as a separator, since
@@ -570,6 +618,9 @@
     // comparing relative paths.
     var lastCodeUnit = chars.SLASH;
 
+    /// The index of the last separator in [parent].
+    int lastParentSeparator;
+
     // Iterate through both paths as long as they're semantically identical.
     var parentIndex = parentRootLength;
     var childIndex = childRootLength;
@@ -577,6 +628,10 @@
       var parentCodeUnit = parent.codeUnitAt(parentIndex);
       var childCodeUnit = child.codeUnitAt(childIndex);
       if (style.codeUnitsEqual(parentCodeUnit, childCodeUnit)) {
+        if (style.isSeparator(parentCodeUnit)) {
+          lastParentSeparator = parentIndex;
+        }
+
         lastCodeUnit = parentCodeUnit;
         parentIndex++;
         childIndex++;
@@ -586,6 +641,7 @@
       // Ignore multiple separators in a row.
       if (style.isSeparator(parentCodeUnit) &&
           style.isSeparator(lastCodeUnit)) {
+        lastParentSeparator = parentIndex;
         parentIndex++;
         continue;
       } else if (style.isSeparator(childCodeUnit) &&
@@ -594,35 +650,34 @@
         continue;
       }
 
-      if (parentCodeUnit == chars.PERIOD) {
-        // If a dot comes after a separator, it may be a directory traversal
-        // operator. To check that, we need to know if it's followed by either
-        // "/" or "./". Otherwise, it's just a normal non-matching character.
-        //
-        //     isWithin("foo/./bar", "foo/bar/baz") //=> true
-        //     isWithin("foo/bar/../baz", "foo/bar/.foo") //=> false
-        if (style.isSeparator(lastCodeUnit)) {
+      // If a dot comes after a separator, it may be a directory traversal
+      // operator. To check that, we need to know if it's followed by either
+      // "/" or "./". Otherwise, it's just a normal non-matching character.
+      //
+      //     isWithin("foo/./bar", "foo/bar/baz") //=> true
+      //     isWithin("foo/bar/../baz", "foo/bar/.foo") //=> false
+      if (parentCodeUnit == chars.PERIOD && style.isSeparator(lastCodeUnit)) {
+        parentIndex++;
+
+        // We've hit "/." at the end of the parent path, which we can ignore,
+        // since the paths were equivalent up to this point.
+        if (parentIndex == parent.length) break;
+        parentCodeUnit = parent.codeUnitAt(parentIndex);
+
+        // We've hit "/./", which we can ignore.
+        if (style.isSeparator(parentCodeUnit)) {
+          lastParentSeparator = parentIndex;
           parentIndex++;
+          continue;
+        }
 
-          // We've hit "/." at the end of the parent path, which we can ignore,
-          // since the paths were equivalent up to this point.
-          if (parentIndex == parent.length) break;
-          parentCodeUnit = parent.codeUnitAt(parentIndex);
-
-          // We've hit "/./", which we can ignore.
-          if (style.isSeparator(parentCodeUnit)) {
-            parentIndex++;
-            continue;
-          }
-
-          // We've hit "/..", which may be a directory traversal operator that
-          // we can't handle on the fast track.
-          if (parentCodeUnit == chars.PERIOD) {
-            parentIndex++;
-            if (parentIndex == parent.length ||
-                style.isSeparator(parent.codeUnitAt(parentIndex))) {
-              return null;
-            }
+        // We've hit "/..", which may be a directory traversal operator that
+        // we can't handle on the fast track.
+        if (parentCodeUnit == chars.PERIOD) {
+          parentIndex++;
+          if (parentIndex == parent.length ||
+              style.isSeparator(parent.codeUnitAt(parentIndex))) {
+            return _PathRelation.inconclusive;
           }
         }
 
@@ -632,23 +687,21 @@
 
       // This is the same logic as above, but for the child path instead of the
       // parent.
-      if (childCodeUnit == chars.PERIOD) {
-        if (style.isSeparator(lastCodeUnit)) {
+      if (childCodeUnit == chars.PERIOD && style.isSeparator(lastCodeUnit)) {
+        childIndex++;
+        if (childIndex == child.length) break;
+        childCodeUnit = child.codeUnitAt(childIndex);
+
+        if (style.isSeparator(childCodeUnit)) {
           childIndex++;
-          if (childIndex == child.length) break;
-          childCodeUnit = child.codeUnitAt(childIndex);
+          continue;
+        }
 
-          if (style.isSeparator(childCodeUnit)) {
-            childIndex++;
-            continue;
-          }
-
-          if (childCodeUnit == chars.PERIOD) {
-            childIndex++;
-            if (childIndex == child.length ||
-                style.isSeparator(child.codeUnitAt(childIndex))) {
-              return null;
-            }
+        if (childCodeUnit == chars.PERIOD) {
+          childIndex++;
+          if (childIndex == child.length ||
+              style.isSeparator(child.codeUnitAt(childIndex))) {
+            return _PathRelation.inconclusive;
           }
         }
       }
@@ -658,11 +711,16 @@
       // ".." components, we can be confident that [child] is not within
       // [parent].
       var childDirection = _pathDirection(child, childIndex);
-      if (childDirection != _PathDirection.belowRoot) return null;
-      var parentDirection = _pathDirection(parent, parentIndex);
-      if (parentDirection != _PathDirection.belowRoot) return null;
+      if (childDirection != _PathDirection.belowRoot) {
+        return _PathRelation.inconclusive;
+      }
 
-      return false;
+      var parentDirection = _pathDirection(parent, parentIndex);
+      if (parentDirection != _PathDirection.belowRoot) {
+        return _PathRelation.inconclusive;
+      }
+
+      return _PathRelation.different;
     }
 
     // If the child is shorter than the parent, it's probably not within the
@@ -672,8 +730,19 @@
     //     isWithin("foo/bar/baz", "foo/bar") //=> false
     //     isWithin("foo/bar/baz/../..", "foo/bar") //=> true
     if (childIndex == child.length) {
-      var direction = _pathDirection(parent, parentIndex);
-      return direction == _PathDirection.aboveRoot ? null : false;
+      if (parentIndex == parent.length ||
+          style.isSeparator(parent.codeUnitAt(parentIndex))) {
+        lastParentSeparator = parentIndex;
+      } else {
+        lastParentSeparator ??= math.max(0, parentRootLength - 1);
+      }
+
+      var direction = _pathDirection(parent,
+          lastParentSeparator ?? parentRootLength - 1);
+      if (direction == _PathDirection.atRoot) return _PathRelation.equal;
+      return direction == _PathDirection.aboveRoot
+          ? _PathRelation.inconclusive
+          : _PathRelation.different;
     }
 
     // We've reached the end of the parent path, which means it's time to make a
@@ -682,11 +751,13 @@
     var direction = _pathDirection(child, childIndex);
 
     // If there are no more components in the child, then it's the same as
-    // the parent, not within it.
+    // the parent.
     //
     //     isWithin("foo/bar", "foo/bar") //=> false
     //     isWithin("foo/bar", "foo/bar//") //=> false
-    if (direction == _PathDirection.atRoot) return false;
+    //     equals("foo/bar", "foo/bar") //=> true
+    //     equals("foo/bar", "foo/bar//") //=> true
+    if (direction == _PathDirection.atRoot) return _PathRelation.equal;
 
     // If there are unresolved ".." components in the child, no decision we make
     // will be valid. We'll abort and do the slow check instead.
@@ -694,7 +765,9 @@
     //     isWithin("foo/bar", "foo/bar/..") //=> false
     //     isWithin("foo/bar", "foo/bar/baz/bang/../../..") //=> false
     //     isWithin("foo/bar", "foo/bar/baz/bang/../../../bar/baz") //=> true
-    if (direction == _PathDirection.aboveRoot) return null;
+    if (direction == _PathDirection.aboveRoot) {
+      return _PathRelation.inconclusive;
+    }
 
     // The child is within the parent if and only if we're on a separator
     // boundary.
@@ -702,12 +775,14 @@
     //     isWithin("foo/bar", "foo/bar/baz") //=> true
     //     isWithin("foo/bar/", "foo/bar/baz") //=> true
     //     isWithin("foo/bar", "foo/barbaz") //=> false
-    return style.isSeparator(child.codeUnitAt(childIndex)) ||
-        style.isSeparator(lastCodeUnit);
+    return (style.isSeparator(child.codeUnitAt(childIndex)) ||
+            style.isSeparator(lastCodeUnit))
+        ? _PathRelation.within
+        : _PathRelation.different;
   }
 
   // Returns a [_PathDirection] describing the path represented by [codeUnits]
-  // after [index].
+  // starting at [index].
   //
   // This ignores leading separators.
   //
@@ -771,6 +846,80 @@
     return _PathDirection.belowRoot;
   }
 
+  /// Returns a hash code for [path] that matches the semantics of [equals].
+  ///
+  /// Note that the same path may have different hash codes in different
+  /// [Context]s.
+  int hash(String path) {
+    // Make [path] absolute to ensure that equivalent relative and absolute
+    // paths have the same hash code.
+    path = absolute(path);
+
+    var result = _hashFast(path);
+    if (result != null) return result;
+
+    var parsed = _parse(path);
+    parsed.normalize();
+    return _hashFast(parsed.toString());
+  }
+
+  /// An optimized implementation of [hash] that doesn't handle internal `..`
+  /// components.
+  ///
+  /// This will handle `..` components that appear at the beginning of the path.
+  int _hashFast(String path) {
+    var hash = 4603;
+    var beginning = true;
+    var wasSeparator = true;
+    for (var i = 0; i < path.length; i++) {
+      var codeUnit = style.canonicalizeCodeUnit(path.codeUnitAt(i));
+
+      // Take advantage of the fact that collisions are allowed to ignore
+      // separators entirely. This lets us avoid worrying about cases like
+      // multiple trailing slashes.
+      if (style.isSeparator(codeUnit)) {
+        wasSeparator = true;
+        continue;
+      }
+
+      if (codeUnit == chars.PERIOD && wasSeparator) {
+        // If a dot comes after a separator, it may be a directory traversal
+        // operator. To check that, we need to know if it's followed by either
+        // "/" or "./". Otherwise, it's just a normal character.
+        //
+        //     hash("foo/./bar") == hash("foo/bar")
+
+        // We've hit "/." at the end of the path, which we can ignore.
+        if (i + 1 == path.length) break;
+
+        var next = path.codeUnitAt(i + 1);
+
+        // We can just ignore "/./", since they don't affect the semantics of
+        // the path.
+        if (style.isSeparator(next)) continue;
+
+        // If the path ends with "/.." or contains "/../", we need to
+        // canonicalize it before we can hash it. We make an exception for ".."s
+        // at the beginning of the path, since those may appear even in a
+        // canonicalized path.
+        if (!beginning &&
+            next == chars.PERIOD &&
+            (i + 2 == path.length ||
+             style.isSeparator(path.codeUnitAt(i + 2)))) {
+          return null;
+        }
+      }
+
+      // Make sure [hash] stays under 32 bits even after multiplication.
+      hash &= 0x3FFFFFF;
+      hash *= 33;
+      hash ^= codeUnit;
+      wasSeparator = false;
+      beginning = false;
+    }
+    return hash;
+  }
+
   /// Removes a trailing extension from the last part of [path].
   ///
   ///     context.withoutExtension('path/to/foo.dart'); // -> 'path/to/foo'
@@ -930,3 +1079,32 @@
 
   String toString() => name;
 }
+
+/// An enum of possible return values for [Context._isWithinOrEquals].
+class _PathRelation {
+  /// The first path is a proper parent of the second.
+  ///
+  /// For example, `foo` is a proper parent of `foo/bar`, but not of `foo`.
+  static const within = const _PathRelation("within");
+
+  /// The two paths are equivalent.
+  ///
+  /// For example, `foo//bar` is equivalent to `foo/bar`.
+  static const equal = const _PathRelation("equal");
+
+  /// The first path is neither a parent of nor equal to the second.
+  static const different = const _PathRelation("different");
+
+  /// We couldn't quickly determine any information about the paths'
+  /// relationship to each other.
+  ///
+  /// Only returned by [Context._isWithinOrEqualsFast].
+  static const inconclusive = const _PathRelation("inconclusive");
+
+  final String name;
+
+  const _PathRelation(this.name);
+
+  String toString() => name;
+}
+
diff --git a/lib/src/internal_style.dart b/lib/src/internal_style.dart
index 1450d53..1874afc 100644
--- a/lib/src/internal_style.dart
+++ b/lib/src/internal_style.dart
@@ -76,4 +76,8 @@
   /// 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;
+
+  int canonicalizeCodeUnit(int codeUnit) => codeUnit;
+
+  String canonicalizePart(String part) => part;
 }
diff --git a/lib/src/parsed_path.dart b/lib/src/parsed_path.dart
index a3d68c7..619ffbf 100644
--- a/lib/src/parsed_path.dart
+++ b/lib/src/parsed_path.dart
@@ -97,7 +97,7 @@
     if (separators.length > 0) separators[separators.length - 1] = '';
   }
 
-  void normalize() {
+  void normalize({bool canonicalize: false}) {
     // Handle '.', '..', and empty parts.
     var leadingDoubles = 0;
     var newParts = <String>[];
@@ -113,7 +113,7 @@
           leadingDoubles++;
         }
       } else {
-        newParts.add(part);
+        newParts.add(canonicalize ? style.canonicalizePart(part) : part);
       }
     }
 
@@ -139,6 +139,7 @@
 
     // Normalize the Windows root if needed.
     if (root != null && style == Style.windows) {
+      if (canonicalize) root = root.toLowerCase();
       root = root.replaceAll('/', '\\');
     }
     removeTrailingSeparators();
diff --git a/lib/src/style/windows.dart b/lib/src/style/windows.dart
index 0d89764..57989af 100644
--- a/lib/src/style/windows.dart
+++ b/lib/src/style/windows.dart
@@ -151,4 +151,13 @@
     }
     return true;
   }
+
+  int canonicalizeCodeUnit(int codeUnit) {
+    if (codeUnit == chars.SLASH) return chars.BACKSLASH;
+    if (codeUnit < chars.UPPER_A) return codeUnit;
+    if (codeUnit > chars.UPPER_Z) return codeUnit;
+    return codeUnit | _asciiCaseBit;
+  }
+
+  String canonicalizePart(String part) => part.toLowerCase();
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index 31bf492..a0475a1 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: path
-version: 1.3.10-dev
+version: 1.4.0-dev
 author: Dart Team <misc@dartlang.org>
 description: >
  A string-based path manipulation library. All of the path operations you know
diff --git a/test/posix_test.dart b/test/posix_test.dart
index 7b55dd3..4549ebd 100644
--- a/test/posix_test.dart
+++ b/test/posix_test.dart
@@ -317,6 +317,12 @@
       expect(context.normalize(r'a/b\'), r'a/b\');
       expect(context.normalize('a/b///'), 'a/b');
     });
+
+    test('when canonicalizing', () {
+      expect(context.canonicalize('.'), '/root/path');
+      expect(context.canonicalize('foo/bar'), '/root/path/foo/bar');
+      expect(context.canonicalize('FoO'), '/root/path/FoO');
+    });
   });
 
   group('relative', () {
@@ -445,6 +451,44 @@
     });
   });
 
+  group('equals and hash', () {
+    test('simple cases', () {
+      expectEquals(context, 'foo/bar', 'foo/bar');
+      expectNotEquals(context, 'foo/bar', 'foo/bar/baz');
+      expectNotEquals(context, 'foo/bar', 'foo');
+      expectNotEquals(context, 'foo/bar', 'foo/baz');
+      expectEquals(context, 'foo/bar', '../path/foo/bar');
+      expectEquals(context, '/', '/');
+      expectEquals(context, '/', '../..');
+      expectEquals(context, 'baz', '/root/path/baz');
+    });
+
+    test('complex cases', () {
+      expectEquals(context, 'foo/./bar', 'foo/bar');
+      expectEquals(context, 'foo//bar', 'foo/bar');
+      expectEquals(context, 'foo/qux/../bar', 'foo/bar');
+      expectNotEquals(context, 'foo/qux/../bar', 'foo/qux');
+      expectNotEquals(context, 'foo/bar', 'foo/bar/baz/../..');
+      expectEquals(context, 'foo/bar', 'foo/bar///');
+      expectEquals(context, 'foo/.bar', 'foo/.bar');
+      expectNotEquals(context, 'foo/./bar', 'foo/.bar');
+      expectEquals(context, 'foo/..bar', 'foo/..bar');
+      expectNotEquals(context, 'foo/../bar', 'foo/..bar');
+      expectEquals(context, 'foo/bar', 'foo/bar/baz/..');
+      expectNotEquals(context, 'FoO/bAr', 'foo/bar');
+    });
+
+    test('from a relative root', () {
+      var r = new path.Context(style: path.Style.posix, current: 'foo/bar');
+      expectEquals(r, 'a/b', 'a/b');
+      expectNotEquals(r, '.', 'foo/bar');
+      expectNotEquals(r, '.', '../a/b');
+      expectEquals(r, '.', '../bar');
+      expectEquals(r, '/baz/bang', '/baz/bang');
+      expectNotEquals(r, 'baz/bang', '/baz/bang');
+    });
+  });
+
   group('absolute', () {
     test('allows up to seven parts', () {
       expect(context.absolute('a'), '/root/path/a');
diff --git a/test/url_test.dart b/test/url_test.dart
index 4adfb48..32c02da 100644
--- a/test/url_test.dart
+++ b/test/url_test.dart
@@ -5,6 +5,8 @@
 import 'package:test/test.dart';
 import 'package:path/path.dart' as path;
 
+import 'utils.dart';
+
 main() {
   var context = new path.Context(
       style: path.Style.url, current: 'http://dartlang.org/root/path');
@@ -427,6 +429,16 @@
       expect(context.normalize(r'a/b\'), r'a/b\');
       expect(context.normalize('a/b///'), 'a/b');
     });
+
+    test('when canonicalizing', () {
+      expect(context.canonicalize('.'), 'http://dartlang.org/root/path');
+      expect(context.canonicalize('foo/bar'),
+          'http://dartlang.org/root/path/foo/bar');
+      expect(context.canonicalize('FoO'), 'http://dartlang.org/root/path/FoO');
+      expect(context.canonicalize('/foo'), 'http://dartlang.org/foo');
+      expect(context.canonicalize('http://google.com/foo'),
+          'http://google.com/foo');
+    });
   });
 
   group('relative', () {
@@ -674,6 +686,52 @@
     });
   });
 
+  group('equals and hash', () {
+    test('simple cases', () {
+      expectEquals(context, 'foo/bar', 'foo/bar');
+      expectNotEquals(context, 'foo/bar', 'foo/bar/baz');
+      expectNotEquals(context, 'foo/bar', 'foo');
+      expectNotEquals(context, 'foo/bar', 'foo/baz');
+      expectEquals(context, 'foo/bar', '../path/foo/bar');
+      expectEquals(context, 'http://google.com', 'http://google.com');
+      expectEquals(context, 'http://dartlang.org', '../..');
+      expectEquals(context, 'baz', '/root/path/baz');
+    });
+
+    test('complex cases', () {
+      expectEquals(context, 'foo/./bar', 'foo/bar');
+      expectEquals(context, 'foo//bar', 'foo/bar');
+      expectEquals(context, 'foo/qux/../bar', 'foo/bar');
+      expectNotEquals(context, 'foo/qux/../bar', 'foo/qux');
+      expectNotEquals(context, 'foo/bar', 'foo/bar/baz/../..');
+      expectEquals(context, 'foo/bar', 'foo/bar///');
+      expectEquals(context, 'foo/.bar', 'foo/.bar');
+      expectNotEquals(context, 'foo/./bar', 'foo/.bar');
+      expectEquals(context, 'foo/..bar', 'foo/..bar');
+      expectNotEquals(context, 'foo/../bar', 'foo/..bar');
+      expectEquals(context, 'foo/bar', 'foo/bar/baz/..');
+      expectNotEquals(context, 'FoO/bAr', 'foo/bar');
+      expectEquals(context, 'http://google.com', 'http://google.com/');
+      expectEquals(context, 'http://dartlang.org/root', '..');
+    });
+
+    test('with root-relative paths', () {
+      expectEquals(context, '/foo', 'http://dartlang.org/foo');
+      expectNotEquals(context, '/foo', 'http://google.com/foo');
+      expectEquals(context, '/root/path/foo/bar', 'foo/bar');
+    });
+
+    test('from a relative root', () {
+      var r = new path.Context(style: path.Style.posix, current: 'foo/bar');
+      expectEquals(r, 'a/b', 'a/b');
+      expectNotEquals(r, '.', 'foo/bar');
+      expectNotEquals(r, '.', '../a/b');
+      expectEquals(r, '.', '../bar');
+      expectEquals(r, '/baz/bang', '/baz/bang');
+      expectNotEquals(r, 'baz/bang', '/baz/bang');
+    });
+  });
+
   group('absolute', () {
     test('allows up to seven parts', () {
       expect(context.absolute('a'), 'http://dartlang.org/root/path/a');
diff --git a/test/utils.dart b/test/utils.dart
index ae3ea46..5e22ce1 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -3,7 +3,30 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import "package:test/test.dart";
-import "package:path/path.dart" as path;
+import "package:path/path.dart" as p;
 
 /// A matcher for a closure that throws a [path.PathException].
-final throwsPathException = throwsA(new isInstanceOf<path.PathException>());
+final throwsPathException = throwsA(new isInstanceOf<p.PathException>());
+
+void expectEquals(p.Context context, String path1, String path2) {
+  expect(context.equals(path1, path2), isTrue,
+      reason: 'Expected "$path1" to equal "$path2".');
+  expect(context.equals(path2, path1), isTrue,
+      reason: 'Expected "$path2" to equal "$path1".');
+  expect(context.hash(path1), equals(context.hash(path2)),
+      reason: 'Expected "$path1" to hash the same as "$path2".');
+}
+
+void expectNotEquals(p.Context context, String path1, String path2,
+    {bool allowSameHash: false}) {
+  expect(context.equals(path1, path2), isFalse,
+      reason: 'Expected "$path1" not to equal "$path2".');
+  expect(context.equals(path2, path1), isFalse,
+      reason: 'Expected "$path2" not to equal "$path1".');
+
+  // Hash collisions are allowed, but the test author should be explicitly aware
+  // when they occur.
+  if (allowSameHash) return;
+  expect(context.hash(path1), isNot(equals(context.hash(path2))),
+      reason: 'Expected "$path1" not to hash the same as "$path2".');
+}
diff --git a/test/windows_test.dart b/test/windows_test.dart
index 8e72bb1..f4453a7 100644
--- a/test/windows_test.dart
+++ b/test/windows_test.dart
@@ -378,6 +378,14 @@
     test('normalizes separators', () {
       expect(context.normalize(r'a/b\c'), r'a\b\c');
     });
+
+    test('when canonicalizing', () {
+      expect(context.canonicalize('.'), r'c:\root\path');
+      expect(context.canonicalize('foo/bar'), r'c:\root\path\foo\bar');
+      expect(context.canonicalize('FoO'), r'c:\root\path\foo');
+      expect(context.canonicalize('/foo'), r'c:\foo');
+      expect(context.canonicalize('D:/foo'), r'd:\foo');
+    });
   });
 
   group('relative', () {
@@ -581,6 +589,53 @@
     });
   });
 
+  group('equals and hash', () {
+    test('simple cases', () {
+      expectEquals(context, r'foo\bar', r'foo\bar');
+      expectNotEquals(context, r'foo\bar', r'foo\bar\baz');
+      expectNotEquals(context, r'foo\bar', r'foo');
+      expectNotEquals(context, r'foo\bar', r'foo\baz');
+      expectEquals(context, r'foo\bar', r'..\path\foo\bar');
+      expectEquals(context, r'D:\', r'D:\');
+      expectEquals(context, r'C:\', r'..\..');
+      expectEquals(context, r'baz', r'C:\root\path\baz');
+    });
+
+    test('complex cases', () {
+      expectEquals(context, r'foo\.\bar', r'foo\bar');
+      expectEquals(context, r'foo\\bar', r'foo\bar');
+      expectEquals(context, r'foo\qux\..\bar', r'foo\bar');
+      expectNotEquals(context, r'foo\qux\..\bar', r'foo\qux');
+      expectNotEquals(context, r'foo\bar', r'foo\bar\baz\..\..');
+      expectEquals(context, r'foo\bar', r'foo\bar\\\');
+      expectEquals(context, r'foo\.bar', r'foo\.bar');
+      expectNotEquals(context, r'foo\.\bar', r'foo\.bar');
+      expectEquals(context, r'foo\..bar', r'foo\..bar');
+      expectNotEquals(context, r'foo\..\bar', r'foo\..bar');
+      expectEquals(context, r'foo\bar', r'foo\bar\baz\..');
+      expectEquals(context, r'FoO\bAr', r'foo\bar');
+      expectEquals(context, r'foo/\bar', r'foo\/bar');
+      expectEquals(context, r'c:\', r'C:\');
+      expectEquals(context, r'C:\root', r'..');
+    });
+
+    test('with root-relative paths', () {
+      expectEquals(context, r'\foo', r'C:\foo');
+      expectNotEquals(context, r'\foo', 'http://google.com/foo');
+      expectEquals(context, r'C:\root\path\foo\bar', r'foo\bar');
+    });
+
+    test('from a relative root', () {
+      var r = new path.Context(style: path.Style.windows, current: r'foo\bar');
+      expectEquals(r, r'a\b', r'a\b');
+      expectNotEquals(r, '.', r'foo\bar');
+      expectNotEquals(r, '.', r'..\a\b');
+      expectEquals(r, '.', r'..\bar');
+      expectEquals(r, r'C:\baz\bang', r'C:\baz\bang');
+      expectNotEquals(r, r'baz\bang', r'C:\baz\bang');
+    });
+  });
+
   group('absolute', () {
     test('allows up to seven parts', () {
       expect(context.absolute('a'), r'C:\root\path\a');