Repository: servo/rust-url
Pull request #668 (2021-01-28 10:57:38)

Add Url::make_relative() as the inverse of Url::join()
This creates a relative URL from a base URL and another absolute URL.

Fixes https://github.com/servo/rust-url/issues/666

sdroege: The clippy failures are all unrelated to this PR. I can submit another PR for them if you agree that all those should be fixed, or let me know which of the lints you'd like to disable (personally I'm not a fan of `clippy::manual-range-contains`...)
djc: Yup, sorry about the clippy failures. Another PR would be nice, though a separate commit in this PR is also fine. I think we should fix all of them except the ones about the unit errors, because those would break backwards compatibility.
sdroege: clippy fixes are in https://github.com/servo/rust-url/pull/669
sdroege: Rebased on top of #669
valenting: Thanks for this PR. It looks really nice.
Would you mind adding a couple of tests for the negative? ie? returning None?
sdroege: Sure, will add a few tests for that later or tomorrow! Thanks for the review
sdroege: Added tests for the 4 possible error cases.
bors-servo: :umbrella: The latest upstream changes (presumably #703) made this pull request unmergeable. Please resolve the merge conflicts.

Patch:
diff --git a/url/src/lib.rs b/url/src/lib.rs
index fd75d47..810002d 100644
--- a/url/src/lib.rs
+++ b/url/src/lib.rs
@@ -320,6 +320,8 @@ impl Url {
 
     /// Parse a string as an URL, with this URL as the base URL.
     ///
+    /// The inverse of this is [`make_relative`].
+    ///
     /// Note: a trailing slash is significant.
     /// Without it, the last path component is considered to be a “file” name
     /// to be removed to get at the “directory” that is used as the base:
@@ -349,11 +351,144 @@ impl Url {
     /// with this URL as the base URL, a [`ParseError`] variant will be returned.
     ///
     /// [`ParseError`]: enum.ParseError.html
+    /// [`make_relative`]: #method.make_relative
     #[inline]
     pub fn join(&self, input: &str) -> Result<Url, crate::ParseError> {
         Url::options().base_url(Some(self)).parse(input)
     }
 
+    /// Creates a relative URL if possible, with this URL as the base URL.
+    ///
+    /// This is the inverse of [`join`].
+    ///
+    /// # Examples
+    ///
+    /// ```rust
+    /// use url::Url;
+    /// # use url::ParseError;
+    ///
+    /// # fn run() -> Result<(), ParseError> {
+    /// let base = Url::parse("https://example.net/a/b.html")?;
+    /// let url = Url::parse("https://example.net/a/c.png")?;
+    /// let relative = base.make_relative(&url);
+    /// assert_eq!(relative.as_ref().map(|s| s.as_str()), Some("c.png"));
+    ///
+    /// let base = Url::parse("https://example.net/a/b/")?;
+    /// let url = Url::parse("https://example.net/a/b/c.png")?;
+    /// let relative = base.make_relative(&url);
+    /// assert_eq!(relative.as_ref().map(|s| s.as_str()), Some("c.png"));
+    ///
+    /// let base = Url::parse("https://example.net/a/b/")?;
+    /// let url = Url::parse("https://example.net/a/d/c.png")?;
+    /// let relative = base.make_relative(&url);
+    /// assert_eq!(relative.as_ref().map(|s| s.as_str()), Some("../d/c.png"));
+    ///
+    /// let base = Url::parse("https://example.net/a/b.html?c=d")?;
+    /// let url = Url::parse("https://example.net/a/b.html?e=f")?;
+    /// let relative = base.make_relative(&url);
+    /// assert_eq!(relative.as_ref().map(|s| s.as_str()), Some("?e=f"));
+    /// # Ok(())
+    /// # }
+    /// # run().unwrap();
+    /// ```
+    ///
+    /// # Errors
+    ///
+    /// If this URL can't be a base for the given URL, `None` is returned.
+    /// This is for example the case if the scheme, host or port are not the same.
+    ///
+    /// [`join`]: #method.join
+    pub fn make_relative(&self, url: &Url) -> Option<String> {
+        if self.cannot_be_a_base() {
+            return None;
+        }
+
+        // Scheme, host and port need to be the same
+        if self.scheme() != url.scheme() || self.host() != url.host() || self.port() != url.port() {
+            return None;
+        }
+
+        // We ignore username/password at this point
+
+        // The path has to be transformed
+        let mut relative = String::new();
+
+        // Extract the filename of both URIs, these need to be handled separately
+        fn extract_path_filename(s: &str) -> (&str, &str) {
+            let last_slash_idx = s.rfind('/').unwrap_or(0);
+            let (path, filename) = s.split_at(last_slash_idx);
+            if filename.is_empty() {
+                (path, "")
+            } else {
+                (path, &filename[1..])
+            }
+        }
+
+        let (base_path, base_filename) = extract_path_filename(self.path());
+        let (url_path, url_filename) = extract_path_filename(url.path());
+
+        let mut base_path = base_path.split('/').peekable();
+        let mut url_path = url_path.split('/').peekable();
+
+        // Skip over the common prefix
+        while base_path.peek().is_some() && base_path.peek() == url_path.peek() {
+            base_path.next();
+            url_path.next();
+        }
+
+        // Add `..` segments for the remainder of the base path
+        for base_path_segment in base_path {
+            // Skip empty last segments
+            if base_path_segment.is_empty() {
+                break;
+            }
+
+            if !relative.is_empty() {
+                relative.push('/');
+            }
+
+            relative.push_str("..");
+        }
+
+        // Append the remainder of the other URI
+        for url_path_segment in url_path {
+            if !relative.is_empty() {
+                relative.push('/');
+            }
+
+            relative.push_str(url_path_segment);
+        }
+
+        // Add the filename if they are not the same
+        if base_filename != url_filename {
+            // If the URIs filename is empty this means that it was a directory
+            // so we'll have to append a '/'.
+            //
+            // Otherwise append it directly as the new filename.
+            if url_filename.is_empty() {
+                relative.push('/');
+            } else {
+                if !relative.is_empty() {
+                    relative.push('/');
+                }
+                relative.push_str(url_filename);
+            }
+        }
+
+        // Query and fragment are only taken from the other URI
+        if let Some(query) = url.query() {
+            relative.push('?');
+            relative.push_str(query);
+        }
+
+        if let Some(fragment) = url.fragment() {
+            relative.push('#');
+            relative.push_str(fragment);
+        }
+
+        Some(relative)
+    }
+
     /// Return a default `ParseOptions` that can fully configure the URL parser.
     ///
     /// # Examples


Test patch:
diff --git a/url/tests/unit.rs b/url/tests/unit.rs
index 57da157..c491e9e 100644
--- a/url/tests/unit.rs
+++ b/url/tests/unit.rs
@@ -960,3 +960,127 @@ fn test_slicing() {
         assert_eq!(&url[..AfterFragment], expected_slices.full);
     }
 }
+
+#[test]
+fn test_make_relative() {
+    let tests = [
+        (
+            "http://127.0.0.1:8080/test",
+            "http://127.0.0.1:8080/test",
+            "",
+        ),
+        (
+            "http://127.0.0.1:8080/test",
+            "http://127.0.0.1:8080/test/",
+            "test/",
+        ),
+        (
+            "http://127.0.0.1:8080/test/",
+            "http://127.0.0.1:8080/test",
+            "../test",
+        ),
+        (
+            "http://127.0.0.1:8080/",
+            "http://127.0.0.1:8080/?foo=bar#123",
+            "?foo=bar#123",
+        ),
+        (
+            "http://127.0.0.1:8080/",
+            "http://127.0.0.1:8080/test/video",
+            "test/video",
+        ),
+        (
+            "http://127.0.0.1:8080/test",
+            "http://127.0.0.1:8080/test/video",
+            "test/video",
+        ),
+        (
+            "http://127.0.0.1:8080/test/",
+            "http://127.0.0.1:8080/test/video",
+            "video",
+        ),
+        (
+            "http://127.0.0.1:8080/test",
+            "http://127.0.0.1:8080/test2/video",
+            "test2/video",
+        ),
+        (
+            "http://127.0.0.1:8080/test/",
+            "http://127.0.0.1:8080/test2/video",
+            "../test2/video",
+        ),
+        (
+            "http://127.0.0.1:8080/test/bla",
+            "http://127.0.0.1:8080/test2/video",
+            "../test2/video",
+        ),
+        (
+            "http://127.0.0.1:8080/test/bla/",
+            "http://127.0.0.1:8080/test2/video",
+            "../../test2/video",
+        ),
+        (
+            "http://127.0.0.1:8080/test/?foo=bar#123",
+            "http://127.0.0.1:8080/test/video",
+            "video",
+        ),
+        (
+            "http://127.0.0.1:8080/test/",
+            "http://127.0.0.1:8080/test/video?baz=meh#456",
+            "video?baz=meh#456",
+        ),
+        (
+            "http://127.0.0.1:8080/test",
+            "http://127.0.0.1:8080/test?baz=meh#456",
+            "?baz=meh#456",
+        ),
+        (
+            "http://127.0.0.1:8080/test/",
+            "http://127.0.0.1:8080/test?baz=meh#456",
+            "../test?baz=meh#456",
+        ),
+        (
+            "http://127.0.0.1:8080/test/",
+            "http://127.0.0.1:8080/test/?baz=meh#456",
+            "?baz=meh#456",
+        ),
+        (
+            "http://127.0.0.1:8080/test/?foo=bar#123",
+            "http://127.0.0.1:8080/test/video?baz=meh#456",
+            "video?baz=meh#456",
+        ),
+    ];
+
+    for (base, uri, relative) in &tests {
+        let base_uri = url::Url::parse(base).unwrap();
+        let relative_uri = url::Url::parse(uri).unwrap();
+        let make_relative = base_uri.make_relative(&relative_uri).unwrap();
+        assert_eq!(
+            make_relative, *relative,
+            "base: {}, uri: {}, relative: {}",
+            base, uri, relative
+        );
+        assert_eq!(
+            base_uri.join(&relative).unwrap().as_str(),
+            *uri,
+            "base: {}, uri: {}, relative: {}",
+            base,
+            uri,
+            relative
+        );
+    }
+
+    let error_tests = [
+        ("http://127.0.0.1:8080/", "https://127.0.0.1:8080/test/"),
+        ("http://127.0.0.1:8080/", "http://127.0.0.1:8081/test/"),
+        ("http://127.0.0.1:8080/", "http://127.0.0.2:8080/test/"),
+        ("mailto:a@example.com", "mailto:b@example.com"),
+    ];
+
+    for (base, uri) in &error_tests {
+        let base_uri = url::Url::parse(base).unwrap();
+        let relative_uri = url::Url::parse(uri).unwrap();
+        let make_relative = base_uri.make_relative(&relative_uri);
+        assert_eq!(make_relative, None, "base: {}, uri: {}", base, uri);
+    }
+}