diff --git a/Sources/PathKit.swift b/Sources/PathKit.swift index df1b047..d30e9df 100644 --- a/Sources/PathKit.swift +++ b/Sources/PathKit.swift @@ -144,7 +144,13 @@ extension Path { /// representation. /// public func normalize() -> Path { - return Path(NSString(string: self.path).standardizingPath) + var path = Path(NSString(string: self.path).standardizingPath) + if !path.isAbsolute { + // standardizingPath only cleans up redundant ".." if the path is absolute. We can perform this normalization + // by recombining the path's components, since + will backtrack on parent directory references. + path = path.components.reduce(Path(), +) + } + return path } /// De-normalizes the path, by replacing the current user home directory with "~". @@ -250,6 +256,60 @@ extension Path { return pathExtension } + + /// Returns the relative path necessary to go from `base` to `self`. + /// + /// Both paths must be absolute or relative paths. + /// - throws: Throws an error when the path types do not match, or when `base` has so many parent path components + /// that it refers to an unknown parent directory. + public func relativePath(from base: Path) throws -> Path { + enum PathArgumentError: Error { + /// Can't back out of an unknown parent directory + case unknownParentDirectory + /// It's impossible to determine the path between an absolute and a relative path + case unmatchedAbsolutePath + } + + func pathComponents(for path: ArraySlice, relativeTo base: ArraySlice, memo: [String]) throws -> [String] { + switch (base.first, path.first) { + // Base case: Paths are equivalent + case (.none, .none): + return memo + + // No path to backtrack from + case (.none, .some(let rhs)): + guard rhs != "." else { + // Skip . instead of appending it + return try pathComponents(for: path.dropFirst(), relativeTo: base, memo: memo) + } + return try pathComponents(for: path.dropFirst(), relativeTo: base, memo: memo + [rhs]) + + // Both sides have a common parent + case (.some(let lhs), .some(let rhs)) where lhs == rhs: + return try pathComponents(for: path.dropFirst(), relativeTo: base.dropFirst(), memo: memo) + + // `base` has a path to back out of + case (.some(let lhs), _): + guard lhs != ".." else { + throw PathArgumentError.unknownParentDirectory + } + guard lhs != "." else { + // Skip . instead of resolving it to .. + return try pathComponents(for: path, relativeTo: base.dropFirst(), memo: memo) + } + return try pathComponents(for: path, relativeTo: base.dropFirst(), memo: memo + [".."]) + } + } + + guard isAbsolute && base.isAbsolute || !isAbsolute && !base.isAbsolute else { + throw PathArgumentError.unmatchedAbsolutePath + } + + return Path(components: try pathComponents(for: ArraySlice(normalize().components), + relativeTo: ArraySlice(base.normalize().components), + memo: [])) + } + } diff --git a/Tests/PathKitTests/PathKitSpec.swift b/Tests/PathKitTests/PathKitSpec.swift index e96b4bc..36511f1 100644 --- a/Tests/PathKitTests/PathKitSpec.swift +++ b/Tests/PathKitTests/PathKitSpec.swift @@ -509,5 +509,64 @@ describe("PathKit") { try expect(paths) == results.sorted(by: <) } } + + $0.describe("relativePath(from:)") { + func relativePath(to path: String, from base: String) throws -> String { + return try Path(path).relativePath(from: Path(base)).string + } + + // These are based on ruby's tests for Pathname#relative_path_from: + // https://github.com/ruby/ruby/blob/7c2bbd1c7d40a30583844d649045824161772e36/test/pathname/test_pathname.rb#L297 + + $0.it("resolves single-level paths") { + try expect(relativePath(to: "a", from: "b")) == "../a" + try expect(relativePath(to: "a", from: "b/")) == "../a" + try expect(relativePath(to: "a/", from: "b")) == "../a" + try expect(relativePath(to: "a/", from: "b/")) == "../a" + try expect(relativePath(to: "/a", from: "/b")) == "../a" + try expect(relativePath(to: "/a", from: "/b/")) == "../a" + try expect(relativePath(to: "/a/", from: "/b")) == "../a" + try expect(relativePath(to: "/a/", from: "/b/")) == "../a" + } + + $0.it("resolves paths with a common parent") { + try expect(relativePath(to: "a/b", from: "a/c")) == "../b" + try expect(relativePath(to: "../a", from: "../b")) == "../a" + } + + $0.it("resolves dot paths") { + try expect(relativePath(to: "a", from: ".")) == "a" + try expect(relativePath(to: ".", from: "a")) == ".." + try expect(relativePath(to: ".", from: ".")) == "." + try expect(relativePath(to: "..", from: "..")) == "." + try expect(relativePath(to: "..", from: ".")) == ".." + } + + $0.it("resolves multi-level paths") { + try expect(relativePath(to: "/a/b/c/d", from: "/a/b")) == "c/d" + try expect(relativePath(to: "/a/b", from: "/a/b/c/d")) == "../.." + try expect(relativePath(to: "/e", from: "/a/b/c/d")) == "../../../../e" + try expect(relativePath(to: "a/b/c", from: "a/d")) == "../b/c" + try expect(relativePath(to: "/../a", from: "/b")) == "../a" + try expect(relativePath(to: "../a", from: "b")) == "../../a" + try expect(relativePath(to: "/a/../../b", from: "/b")) == "." + try expect(relativePath(to: "a/..", from: "a")) == ".." + try expect(relativePath(to: "a/../b", from: "b")) == "." + } + + $0.it("backtracks on a non-normalized base path") { + try expect(relativePath(to: "a", from: "b/..")) == "a" + try expect(relativePath(to: "b/c", from: "b/..")) == "b/c" + } + + $0.it("throws when given unresolvable paths") { + try expect(relativePath(to: "/", from: ".")).toThrow() + try expect(relativePath(to: ".", from: "/")).toThrow() + try expect(relativePath(to: "a", from: "..")).toThrow() + try expect(relativePath(to: ".", from: "..")).toThrow() + try expect(relativePath(to: "a", from: "b/../..")).toThrow() + } + } + } }