Swift equivalent of Ruby's "Pathname.relative_path_from"

There is no such method in the Swift standard library or in the Foundation framework, as far as I know.

Here is a possible implementation as an extension method of URL:

extension URL {
    func relativePath(from base: URL) -> String? {
        // Ensure that both URLs represent files:
        guard self.isFileURL && base.isFileURL else {
            return nil
        }

        // Remove/replace "." and "..", make paths absolute:
        let destComponents = self.standardized.pathComponents
        let baseComponents = base.standardized.pathComponents

        // Find number of common path components:
        var i = 0
        while i < destComponents.count && i < baseComponents.count
            && destComponents[i] == baseComponents[i] {
                i += 1
        }

        // Build relative path:
        var relComponents = Array(repeating: "..", count: baseComponents.count - i)
        relComponents.append(contentsOf: destComponents[i...])
        return relComponents.joined(separator: "/")
    }
}

My test code:

func test(_ p1: String, _ p2: String) {
    let u1 = URL(fileURLWithPath: p1)
    let u2 = URL(fileURLWithPath: p2)
    print(u1.relativePath(from: u2) ?? "<ERROR>")
}
test("/usr/X11/agent/47.gz",      "/usr/X11")        // "agent/47.gz"
test("/usr/share/man/meltdown.1", "/usr/share/cups") // "../man/meltdown.1"
test("/var/logs/x/y/z/log.txt",   "/var/logs")       // "x/y/z/log.txt"

Remarks:

  • "." and ".." in the given URLs are removed, and relative file URLs are made absolute (both using the standardized method of URL).
  • Case (in)sensitivity is not handled.
  • It is assumed that the base URL represents a directory.

Addendum: @neoneye wrapped this into a Swift package: SwiftyRelativePath.


Martin R had the right answer. However, I had an issue when the base URL is a file itself. Hence, I did a bit of tweaking:

func relativePath(from base: URL) -> String? {
    // Ensure that both URLs represent files:
    guard self.isFileURL && base.isFileURL else {
        return nil
    }

    //this is the new part, clearly, need to use workBase in lower part
    var workBase = base
    if workBase.pathExtension != "" {
        workBase = workBase.deletingLastPathComponent()
    }

    // Remove/replace "." and "..", make paths absolute:
    let destComponents = self.standardized.resolvingSymlinksInPath().pathComponents
    let baseComponents = workBase.standardized.resolvingSymlinksInPath().pathComponents

    // Find number of common path components:
    var i = 0
    while i < destComponents.count &&
          i < baseComponents.count &&
          destComponents[i] == baseComponents[i] {
            i += 1
    }

    // Build relative path:
    var relComponents = Array(repeating: "..", count: baseComponents.count - i)
    relComponents.append(contentsOf: destComponents[i...])
    return relComponents.joined(separator: "/")
}

My test case got a bit extended. Case 4 was my trigger for this small change.

 func testRelativePath() {
    // This is an example of a functional test case.
    // Use XCTAssert and related functions to verify your tests produce the correct results.
    func test(_ p1: String, _ p2: String,_ result: String,_ nr: Int) {
        let u1 = URL(fileURLWithPath: p1)
        let u2 = URL(fileURLWithPath: p2)
        let r = u1.relativePath(from: u2)!
        XCTAssert( r == result,"\(nr): '\(r)' != '\(result)'")
    }
    test("/usr/X11/agent/47.gz",      "/usr/X11","agent/47.gz", 1)
    test("/usr/share/man/meltdown.1", "/usr/share/cups", "../man/meltdown.1",2 )
    test("/var/logs/x/y/z/log.txt",   "/var/logs", "x/y/z/log.txt",3)
    test("/usr/embedded.jpg",   "/usr/main.html", "embedded.jpg",4)
    test("/usr/embedded.jpg",   "/usr", "embedded.jpg",5)
    test("~/Downloads/resources",   "~/", "Downloads/resources",6)
    test("~/Downloads/embedded.jpg",   "~/Downloads/main.html", "embedded.jpg",7)
    test("/private/var/logs/x/y/z/log.txt", "/var/logs", "x/y/z/log.txt",8)
 }