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.

neoneye
  • 50,398
  • 25
  • 166
  • 151
  • 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 https://stackoverflow.com/a/40401137/1187415). On macOS that should fix the case insensitivity as well. – Martin R Jan 23 '18 at 14:56
  • 1
    It seems to be unimplemented on Linux: https://github.com/apple/swift-corelibs-foundation/blob/master/Foundation/NSURL.swift#L644. Using `realpath` would be an alternative. – Martin R Jan 23 '18 at 15:43
  • On macOS there can be symlinks and aliases. Great article about how to deal with this in objc: https://www.cocoawithlove.com/2010/02/resolving-path-containing-mixture-of.html – neoneye Jan 23 '18 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. – neoneye Jan 23 '18 at 22:30

3 Answers3

11

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
  • 529,903
  • 94
  • 1,240
  • 1,382
  • 1
    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`. – Abel Toy Dec 24 '19 at 01:14
  • 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`. – Daniel Molina Sep 23 '22 at 15:25
  • 1
    @DanielMolina: The second argument is assumed to be a *directory* path. – Martin R Sep 24 '22 at 01:19
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)
 }
Wizard of Kneup
  • 1,863
  • 1
  • 18
  • 35
  • 1
    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. – Abel Toy Dec 24 '19 at 01:16
  • Thanks Abel, I added your suggestions. – Wizard of Kneup Dec 25 '19 at 07:38
  • 1
    Is it assumed that a directory name does not have an extension? – psksvp Feb 12 '20 at 11:21
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: "/")
    }
}
psksvp
  • 139
  • 11
  • The set operations scramble the order of path components, so the code produces nonsense paths. – Anton Aug 30 '20 at 17:57
  • 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 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> – psksvp Sep 18 '20 at 01:01
  • 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. – Anton Sep 19 '20 at 02:34