Skip to content

Instantly share code, notes, and snippets.

@DivineDominion
Last active January 31, 2022 10:37
Show Gist options
  • Save DivineDominion/3e593e0f40d229b199246f3e3492ff0f to your computer and use it in GitHub Desktop.
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`
// 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
}
}
// 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