8

Ruby's Pathname.relative_path_from documentation.

In objc there is already KSFileUtilities' ks_stringRelativeToURL method, that is very close. I'm looking for a pure swift solution that can run on Linux.

I prefer a solution uses file:// URL's, but String is also fine.

Filesystems can be case sensitive/insensitive. It may be tricky to determine the relative path.

Example of inputs and expected output:

| Long Path                      | Relative to Path | Return Value      |
|--------------------------------|------------------|-------------------|
| /usr/X11/agent/47.gz           | /usr/X11         | agent/47.gz       |
| /usr/share/man/meltdown.1      | /usr/share/cups  | ../man/meltdown.1 |
| file:///var/logs/x/y/z/log.txt | file:///var/logs | x/y/z/log.txt     |

Swift already has FileManager.getRelationship(_:of:in:toItemAt:), but it doesn't return a relative path.

4
  • 1
    I just saw your posting on the Swift forum, and Quinn's advice to canonicalize the path. The reasons I did not do that in my suggested solution were 1) that would access the file system (which the Ruby function does not), 2) I wasn't sure if that works on Linux. But I can add that later (compare stackoverflow.com/a/40401137/1187415). On macOS that should fix the case insensitivity as well. Commented Jan 23, 2018 at 14:56
  • 1
    It seems to be unimplemented on Linux: github.com/apple/swift-corelibs-foundation/blob/master/…. Using realpath would be an alternative. Commented Jan 23, 2018 at 15:43
  • On macOS there can be symlinks and aliases. Great article about how to deal with this in objc: cocoawithlove.com/2010/02/… Commented Jan 23, 2018 at 22:15
  • @MartinR no need for you to do anything. Your resolvePath(from:) function is great as it is now, assuming the caller provides absolute paths without any parentdir/symlink/alias. This narrow scope is fine for me. Dealing with relative paths from cwd containing parentdir/symlink/alias is a minefield. Commented Jan 23, 2018 at 22:30

3 Answers 3

12

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.

Sign up to request clarification or add additional context in comments.

3 Comments

This can give unpredictable results if one of the URLs provided has symlinks and the other has not (i.e. an URL that starts with /private/var and one that starts with /var. To fix this, resolvingSymlinksInPath() needs to be appended after standardized in both dest and baseComponents.
If both URLs point to a file in the same directory, this implementation will incorrectly add "../" to the beginning of the relative path. The correct approach would be to add "./". You need to start relComponents with ["."] when (baseComponents.count - i) == 1.
@DanielMolina: The second argument is assumed to be a directory path.
4

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)
 }

3 Comments

This can give unpredictable results if one of the URLs provided has symlinks and the other has not (i.e. an URL that starts with /private/var and one that starts with /var. To fix this, resolvingSymlinksInPath() needs to be appended after standardized in both dest and baseComponents. You can add an additional test: test("/private/var/logs/x/y/z/log.txt", "/var/logs", "x/y/z/log.txt",8), though log.txt must exist for the test to fail.
Thanks Abel, I added your suggestions.
Is it assumed that a directory name does not have an extension?
0

I was using the version by Wizard of Kneup but had problem when the base dir has an extension. So I add code to check if path exists and is a dir.

public extension URL 
{
    func relativePath(from base: URL) -> String?
    {
        // Ensure that both URLs represent files:
        guard self.isFileURL &&
              FileManager.default.fileExists(atPath: self.path) else
        {
            NSLog("self is not a fileURL or it does not exists")
            return nil
        }

        var isDir = ObjCBool(true)
        guard FileManager.default.fileExists(atPath: base.path, isDirectory: &isDir) &&
              isDir.boolValue else
        {
            NSLog("base is not a directory or it does not exists")
            return nil
        }           


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

        // Find number of common path components:
        let i = Set(destComponents).intersection(Set(baseComponents)).count

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

3 Comments

The set operations scramble the order of path components, so the code produces nonsense paths.
Did you have a test case? As fas as I remember, Set was used to count common components, the result of the intersection was not used. I did a quick test below <code> 154> let m = URL(fileURLWithPath: "/Users/psksvp/workspace/scala.js/build.sbt") m: URL = {} 155> m.debugDescription $R6: String = "file:///Users/psksvp/workspace/scala.js/build.sbt" 156> m.relativePath(from: URL(fileURLWithPath: "/Users/psksvp/workspace/ssc")) $R7: String? = "../scala.js/build.sbt" 157> </code>
Let's try a very simple example: base = /a/a dest=/b/a. The desired output is ../../b/a, your method produces ../a, because the intersection is {a} and i = 1. Your "let i =..." line should be counting the length of the longest common prefix (in components), but the set operation scrambles the order -- you lost the prefix part -- and then gives you the wrong count if a path component occurs multiple times in one of the paths.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.