Merge pull request #20 from dart-lang/windows-drive-letters

Update Windows file URI behavior.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2818f7b..f7dde67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+## 1.4.1
+
+* Root-relative URLs like `/foo` are now resolved relative to the drive letter
+  for `file` URLs that begin with a Windows-style drive letter. This matches the
+  [WHATWG URL specification][].
+
+[WHATWG URL specification]: https://url.spec.whatwg.org/#file-slash-state
+
+* When a root-relative URLs like `/foo` is converted to a Windows path using
+  `fromUrl()`, it is now resolved relative to the drive letter. This matches
+  IE's behavior.
+
 ## 1.4.0
 
 * Add `equals()`, `hash()` and `canonicalize()` top-level functions and
diff --git a/README.md b/README.md
index 5a172cd..5803470 100644
--- a/README.md
+++ b/README.md
@@ -41,13 +41,34 @@
 This will join "directory" and "file.txt" using the Windows path separator,
 even when the program is run on a POSIX machine.
 
+## Stability
+
+The `path` package is used by many Dart packages, and as such it strives for a
+very high degree of stability. For the same reason, though, releasing a new
+major version would probably cause a lot of versioning pain, so some flexibility
+is necessary.
+
+We try to guarantee that **operations with valid inputs and correct output will
+not change**. Operations where one or more inputs are invalid according to the
+semantics of the corresponding platform may produce different output over time.
+Operations for which `path` produces incorrect output will also change so that
+we can fix bugs.
+
+Also, the `path` package's URL handling is based on [the WHATWG URL spec][].
+This is a living standard, and some parts of it haven't yet been entirely
+solidified by vendor support. The `path` package reserves the right to change
+its URL behavior if the underlying specification changes, although if the change
+is big enough to break many valid uses we may elect to treat it as a breaking
+change anyway.
+
+[the WHATWG URL spec]: https://url.spec.whatwg.org/
+
 ## FAQ
 
 ### Where can I use this?
 
-Pathos runs on the Dart VM and in the browser under both dart2js and Dartium.
-Under dart2js, it currently returns "." as the current working directory, while
-under Dartium it returns the current URL.
+The `path` package runs on the Dart VM and in the browser under both dart2js and
+Dartium. On the browser, `window.location.href` is used as the current path.
 
 ### Why doesn't this make paths first-class objects?
 
diff --git a/lib/src/context.dart b/lib/src/context.dart
index af97f90..c5ebb69 100644
--- a/lib/src/context.dart
+++ b/lib/src/context.dart
@@ -245,7 +245,9 @@
         // If the new part is root-relative, it preserves the previous root but
         // replaces the path after it.
         var parsed = _parse(part);
-        parsed.root = this.rootPrefix(buffer.toString());
+        var path = buffer.toString();
+        parsed.root = path.substring(
+            0, style.rootLength(path, withDrive: true));
         if (style.needsSeparator(parsed.root)) {
           parsed.separators[0] = style.separator;
         }
diff --git a/lib/src/internal_style.dart b/lib/src/internal_style.dart
index 1874afc..21897d6 100644
--- a/lib/src/internal_style.dart
+++ b/lib/src/internal_style.dart
@@ -33,10 +33,11 @@
 
   /// Returns the number of characters of the root part.
   ///
-  /// Returns 0 if the path is relative.
+  /// Returns 0 if the path is relative and 1 if the path is root-relative.
   ///
-  /// If the path is root-relative, the root length is 1.
-  int rootLength(String path);
+  /// If [withDrive] is `true`, this should include the drive letter for `file:`
+  /// URLs. Non-URL styles may ignore the parameter.
+  int rootLength(String path, {bool withDrive: false});
 
   /// Gets the root prefix of [path] if path is absolute. If [path] is relative,
   /// returns `null`.
diff --git a/lib/src/style/posix.dart b/lib/src/style/posix.dart
index 6f183fc..5044d43 100644
--- a/lib/src/style/posix.dart
+++ b/lib/src/style/posix.dart
@@ -28,7 +28,7 @@
   bool needsSeparator(String path) =>
       path.isNotEmpty && !isSeparator(path.codeUnitAt(path.length - 1));
 
-  int rootLength(String path) {
+  int rootLength(String path, {bool withDrive: false}) {
     if (path.isNotEmpty && isSeparator(path.codeUnitAt(0))) return 1;
     return 0;
   }
diff --git a/lib/src/style/url.dart b/lib/src/style/url.dart
index 659275c..f4bab64 100644
--- a/lib/src/style/url.dart
+++ b/lib/src/style/url.dart
@@ -4,6 +4,7 @@
 
 import '../characters.dart' as chars;
 import '../internal_style.dart';
+import '../utils.dart';
 
 /// The style for URL paths.
 class UrlStyle extends InternalStyle {
@@ -36,16 +37,23 @@
     return path.endsWith("://") && rootLength(path) == path.length;
   }
 
-  int rootLength(String path) {
+  int rootLength(String path, {bool withDrive: false}) {
     if (path.isEmpty) return 0;
     if (isSeparator(path.codeUnitAt(0))) return 1;
+
     var index = path.indexOf("/");
     if (index > 0 && path.startsWith('://', index - 1)) {
       // The root part is up until the next '/', or the full path. Skip
       // '://' and search for '/' after that.
       index = path.indexOf('/', index + 2);
-      if (index > 0) return index;
-      return path.length;
+      if (index <= 0) return path.length;
+
+      // file: URLs sometimes consider Windows drive letters part of the root.
+      // See https://url.spec.whatwg.org/#file-slash-state.
+      if (!withDrive || path.length < index + 3) return index;
+      if (!path.startsWith('file://')) return index;
+      if (!isDriveLetter(path, index + 1)) return index;
+      return path.length == index + 3 ? index + 3 : index + 4;
     }
     return 0;
   }
diff --git a/lib/src/style/windows.dart b/lib/src/style/windows.dart
index 57989af..31114db 100644
--- a/lib/src/style/windows.dart
+++ b/lib/src/style/windows.dart
@@ -36,7 +36,7 @@
     return !isSeparator(path.codeUnitAt(path.length - 1));
   }
 
-  int rootLength(String path) {
+  int rootLength(String path, {bool withDrive: false}) {
     if (path.isEmpty) return 0;
     if (path.codeUnitAt(0) == chars.SLASH) return 1;
     if (path.codeUnitAt(0) == chars.BACKSLASH) {
@@ -78,8 +78,13 @@
     var path = uri.path;
     if (uri.host == '') {
       // Drive-letter paths look like "file:///C:/path/to/file". The
-      // replaceFirst removes the extra initial slash.
-      if (path.startsWith('/')) path = path.replaceFirst("/", "");
+      // replaceFirst removes the extra initial slash. Otherwise, leave the
+      // slash to match IE's interpretation of "/foo" as a root-relative path.
+      if (path.length >= 3 &&
+          path.startsWith('/') &&
+          isDriveLetter(path, 1)) {
+        path = path.replaceFirst("/", "");
+      }
     } else {
       // Network paths look like "file://hostname/path/to/file".
       path = '\\\\${uri.host}$path';
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 64e471e..3d71e56 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -12,3 +12,13 @@
 
 /// Returns whether [char] is the code for an ASCII digit.
 bool isNumeric(int char) => char >= chars.ZERO && char <= chars.NINE;
+
+/// Returns whether [path] has a URL-formatted Windows drive letter beginning at
+/// [index].
+bool isDriveLetter(String path, int index) {
+  if (path.length < index + 2) return false;
+  if (!isAlphabetic(path.codeUnitAt(index))) return false;
+  if (path.codeUnitAt(index + 1) != chars.COLON) return false;
+  if (path.length == index + 2) return true;
+  return path.codeUnitAt(index + 2) == chars.SLASH;
+}
diff --git a/test/url_test.dart b/test/url_test.dart
index 32c02da..e8f3413 100644
--- a/test/url_test.dart
+++ b/test/url_test.dart
@@ -245,13 +245,33 @@
       expect(() => context.join(null, 'a'), throwsArgumentError);
     });
 
-    test('Join does not modify internal ., .., or trailing separators', () {
+    test('does not modify internal ., .., or trailing separators', () {
       expect(context.join('a/', 'b/c/'), 'a/b/c/');
       expect(context.join('a/b/./c/..//', 'd/.././..//e/f//'),
           'a/b/./c/..//d/.././..//e/f//');
       expect(context.join('a/b', 'c/../../../..'), 'a/b/c/../../../..');
       expect(context.join('a', 'b${context.separator}'), 'a/b/');
     });
+
+    test('treats drive letters as part of the root for file: URLs', () {
+      expect(context.join('file:///c:/foo/bar', '/baz/qux'),
+          'file:///c:/baz/qux');
+      expect(context.join('file:///D:/foo/bar', '/baz/qux'),
+          'file:///D:/baz/qux');
+      expect(context.join('file:///c:/', '/baz/qux'), 'file:///c:/baz/qux');
+      expect(context.join('file:///c:', '/baz/qux'), 'file:///c:/baz/qux');
+      expect(context.join('file://host/c:/foo/bar', '/baz/qux'),
+          'file://host/c:/baz/qux');
+    });
+
+    test('treats drive letters as normal components for non-file: URLs', () {
+      expect(context.join('http://foo.com/c:/foo/bar', '/baz/qux'),
+          'http://foo.com/baz/qux');
+      expect(context.join('misfile:///c:/foo/bar', '/baz/qux'),
+          'misfile:///baz/qux');
+      expect(context.join('filer:///c:/foo/bar', '/baz/qux'),
+          'filer:///baz/qux');
+    });
   });
 
   group('joinAll', () {
diff --git a/test/windows_test.dart b/test/windows_test.dart
index f4453a7..ad58774 100644
--- a/test/windows_test.dart
+++ b/test/windows_test.dart
@@ -704,6 +704,7 @@
           r'\\server\share\path\to\foo#bar');
       expect(context.fromUri(Uri.parse('_%7B_%7D_%60_%5E_%20_%22_%25_')),
           r'_{_}_`_^_ _"_%_');
+      expect(context.fromUri(Uri.parse('/foo')), r'\foo');
       expect(() => context.fromUri(Uri.parse('http://dartlang.org')),
           throwsArgumentError);
     });