Last active
January 31, 2022 10:37
-
-
Save DivineDominion/3e593e0f40d229b199246f3e3492ff0f to your computer and use it in GitHub Desktop.
Given two file path URLs, determine the shortest relative path to get from one to the other, e.g. `../../folder/file.txt`
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright © 2021 Christian Tietze. All rights reserved. Distributed under the MIT License. | |
import Foundation | |
extension URL { | |
/// Produces a relative path to get from `baseURL` to the receiver for use in e.g. labels. | |
/// | |
/// When there's no common ancestor, e.g. `/tmp/` and `/var/`, then this returns an absolute path. The only exception to this rule is when `baseURL` itself is the root `/`, since tor that base _all_ paths are relative. | |
/// | |
/// - Returns: Shortest relative path to get from `baseURL` to receiver, e.g. `"../../subdirectory/file.txt"`, and `"."` if both are identical. Absolute path (or absolute URL for non-`file://` URLs) if there's nothing in common. | |
func relativePath(resolvedAgainst baseURL: URL) -> String { | |
guard let url = self.relativeURL(resolvedAgainst: baseURL) else { | |
if self.isFileURL { | |
// Produce absolute file path | |
return self.path | |
} else if self.scheme == baseURL.scheme && self.host == baseURL.host { | |
// For e.g. web URLs, if protocol and domain are the same, drop the shared part and return only the absolute path. | |
return self.path | |
} else { | |
// If everything differs in non-file URLs, produce the whole URL string. | |
return self.absoluteString | |
} | |
} | |
let path = url.relativePath | |
if path.hasPrefix("./") { | |
// Avoid "./file.txt" and "./../sibling/path.txt" by dropping the current dir part. | |
return String(path.dropFirst(2)) | |
} else { | |
return path | |
} | |
} | |
/// - Returns: `nil` if the URLs cannot be compared (e.g. file vs http scheme) or have nothing in common. | |
private func relativeURL(resolvedAgainst baseURL: URL) -> URL? { | |
// Protect against cross-domain or cross-scheme URL comparison attempts. | |
guard self.scheme == baseURL.scheme, | |
self.host == baseURL.host | |
else { | |
return nil | |
} | |
// Ignore file in base directory path. | |
guard baseURL.hasDirectoryPath else { | |
return self.relativeURL(resolvedAgainst: baseURL.deletingLastPathComponent()) | |
} | |
// Ignore the file when comparing the reference URL (self) to baseURL, but do preserve the file for a full path. | |
guard self.hasDirectoryPath else { | |
// Append target file name to result to get not just the path directions, but the total result. The use of an array and filter gets rid of empty `resolvedDirectoryPath` strings in one go, i.e when the base directory and the current directory are one and the same. | |
return self.deletingLastPathComponent() | |
.relativeURL(resolvedAgainst: baseURL)? | |
.appendingPathComponent(self.lastPathComponent) | |
} | |
// We can rely on `pathComponents` producing absolute paths: even when using the relative URL initializer, `pathComponents` are resolved using the implicit base URL during initialization (for Xcode tests, that's the derived data path, and in the Swift REPL the working directory of the shell). | |
let sharedPathComponents = self.pathComponents.commonPrefix(baseURL.pathComponents) | |
// No path component in common with `baseURL`. (Except when base is root.) | |
if sharedPathComponents == ["/"] | |
&& baseURL.pathComponents != ["/"] { | |
return nil | |
} | |
let uniqueBasePathComponents = baseURL.pathComponents.dropFirst(sharedPathComponents.count) | |
let uniqueReferencePathComponents = self.pathComponents.dropFirst(sharedPathComponents.count) | |
let goToParent = uniqueBasePathComponents.map { _ in ".." } | |
let drillDownToPath = uniqueReferencePathComponents | |
return (goToParent + drillDownToPath) | |
.reduce(URL(fileURLWithPath: "", relativeTo: baseURL)) { $0.appendingPathComponent($1) } | |
} | |
} | |
extension Array where Element: Equatable { | |
func commonPrefix(_ other: [Element]) -> [Element] { | |
var result: [Element] = [] | |
for (lhs, rhs) in zip(self, other) { | |
if lhs == rhs { | |
result.append(lhs) | |
} else { | |
break | |
} | |
} | |
return result | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright © 2021 Christian Tietze. All rights reserved. Distributed under the MIT License. | |
import XCTest | |
// @testable import TheAppOrLibTarget | |
class RelativeURLResolvingTests: XCTestCase { | |
func testWebURLs() { | |
XCTAssertEqual( | |
URL("https://example.com/folder/index.html") | |
.relativePath(resolvedAgainst: URL("https://example.com/root.txt")), | |
"folder/index.html") | |
XCTAssertEqual( | |
URL("https://example.com/index.html") | |
.relativePath(resolvedAgainst: URL("https://example.com/path/file.txt")), | |
"/index.html", | |
"Nothing in common except scheme and domain") | |
XCTAssertEqual( | |
URL("https://example.com/index.html") | |
.relativePath(resolvedAgainst: URL("https://example.com/")), | |
"index.html", | |
"Detecting common root as shared parent path") | |
XCTAssertEqual( | |
URL("https://example.com/path/index.html") | |
.relativePath(resolvedAgainst: URL("https://example.com/path/other.html")), | |
"index.html") | |
XCTAssertEqual( | |
URL("https://example.com/index.html") | |
.relativePath(resolvedAgainst: URL("https://different.de/path/file.txt")), | |
"https://example.com/index.html", | |
"Same path is irrelevant if host doesn't match") | |
XCTAssertEqual( | |
URL("ftp://warez.ru/foo/bar/") | |
.relativePath(resolvedAgainst: URL("http://warez.ru/foo/bar/")), | |
"ftp://warez.ru/foo/bar/", | |
"Same path is irrelevant if scheme doesn't match") | |
} | |
func testRootBase_AddingDirectory() { | |
let base = URL(fileURLWithPath: "/") | |
let path = URL(fileURLWithPath: "/dir/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "dir") | |
} | |
func testRootBase_AddingFile() { | |
let base = URL(fileURLWithPath: "/") | |
let path = URL(fileURLWithPath: "/file") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "file") | |
} | |
func testRootBase_WithFileInRoot_AddingDirectory() { | |
let base = URL(fileURLWithPath: "/irrelevant") | |
let path = URL(fileURLWithPath: "/dir/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "dir") | |
} | |
func testRootBase_WithFileInRoot_AddingFile() { | |
let base = URL(fileURLWithPath: "/irrelevant") | |
let path = URL(fileURLWithPath: "/file") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "file") | |
} | |
func testBaseDirContainedFully() { | |
let base = URL(fileURLWithPath: "/base/path/") | |
let path = URL(fileURLWithPath: "/base/path/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), ".") | |
} | |
func testBaseDirContainedFully_AddingDirectory() { | |
let base = URL(fileURLWithPath: "/tmp/") | |
let path = URL(fileURLWithPath: "/tmp/dir/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "dir") | |
} | |
func testBaseDirContainedFully_AddingFile() { | |
let base = URL(fileURLWithPath: "/tmp/") | |
let path = URL(fileURLWithPath: "/tmp/file") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "file") | |
} | |
func testBaseDirContainedFully_WithFileInBaseDir_AddingDirectory() { | |
let base = URL(fileURLWithPath: "/tmp/irrelevant") | |
let path = URL(fileURLWithPath: "/tmp/dir/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "dir") | |
} | |
func testBaseDirContainedFully_WithFileInBaseDir_AddingFile() { | |
let base = URL(fileURLWithPath: "/tmp/irrelevant") | |
let path = URL(fileURLWithPath: "/tmp/file") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "file") | |
} | |
func testSiblingToLastBaseDir() { | |
let base = URL(fileURLWithPath: "/base/directory/") | |
let path = URL(fileURLWithPath: "/base/sibling/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "../sibling") | |
} | |
func testSiblingToLastBaseDir_WithFileInBaseDir() { | |
let base = URL(fileURLWithPath: "/base/directory/irrelevant") | |
let path = URL(fileURLWithPath: "/base/sibling/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "../sibling") | |
} | |
func testSiblingWithFileToLastBaseDir() { | |
let base = URL(fileURLWithPath: "/base/directory/") | |
let path = URL(fileURLWithPath: "/base/sibling/file") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "../sibling/file") | |
} | |
func testSiblingWithFileToLastBaseDir_WithFileInBaseDir() { | |
let base = URL(fileURLWithPath: "/base/directory/irrelevant") | |
let path = URL(fileURLWithPath: "/base/sibling/file") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "../sibling/file") | |
} | |
func testAncestorOfBase() { | |
let base = URL(fileURLWithPath: "/base/path/to/its/fullest/") | |
let path = URL(fileURLWithPath: "/base/path/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "../../..") | |
} | |
func testSiblingToParentOfParentOfBaseDir() { | |
let base = URL(fileURLWithPath: "/base/path/to/its/fullest/") | |
let path = URL(fileURLWithPath: "/base/path/sibling/") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "../../../sibling") | |
} | |
func testNothingInCommon() { | |
let base = URL(fileURLWithPath: "/base/path/") | |
let path = URL(fileURLWithPath: "/absolute/path") | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "/absolute/path") | |
} | |
func testRelativePath() { | |
let base = URL(fileURLWithPath: "/base/path/") | |
let path = URL(fileURLWithPath: "../sibling/file", relativeTo: base) | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "../sibling/file") | |
} | |
func testRelativePathToBaseParent() { | |
let base = URL(fileURLWithPath: "/base/parent/path/") | |
let path = URL(fileURLWithPath: "../sibling/file", relativeTo: URL(fileURLWithPath: "/base/parent/")) | |
XCTAssertEqual(path.relativePath(resolvedAgainst: base), "../../sibling/file") | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment