Symlink funcs & support NSURL file-refs
* Also removes most `NSString` usage * Also does more thorough testing in some places * Also adds * Fixes `Path?(_:)` resolving symlinks in some cases
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import Foundation
|
||||
#if !os(Linux)
|
||||
import func Darwin.realpath
|
||||
let _realpath = Darwin.realpath
|
||||
#else
|
||||
import func Glibc.realpath
|
||||
let _realpath = Glibc.realpath
|
||||
#endif
|
||||
|
||||
/**
|
||||
A `Path` represents an absolute path on a filesystem.
|
||||
@@ -32,19 +39,85 @@ import Foundation
|
||||
public struct Path: Equatable, Hashable, Comparable {
|
||||
|
||||
init(string: String) {
|
||||
assert(string.first == "/")
|
||||
assert(string.last != "/" || string == "/")
|
||||
assert(string.split(separator: "/").contains("..") == false)
|
||||
self.string = string
|
||||
}
|
||||
|
||||
/// Returns `nil` unless fed an absolute path.
|
||||
/**
|
||||
Creates a new absolute, standardized path.
|
||||
- Note: Resolves any .. or . components.
|
||||
- Note: Removes multiple subsequent and trailing occurences of `/`.
|
||||
- Note: Does *not* resolve any symlinks.
|
||||
- Note: On macOS, removes an initial component of “/private/var/automount”, “/var/automount”, or “/private” from the path, if the result still indicates an existing file or directory (checked by consulting the file system).
|
||||
- Returns: The path or `nil` if fed a relative path or a `~foo` string where there is no user `foo`.
|
||||
*/
|
||||
public init?(_ description: String) {
|
||||
guard description.starts(with: "/") || description.starts(with: "~/") else { return nil }
|
||||
self.init(string: (description as NSString).standardizingPath)
|
||||
var pathComponents = description.split(separator: "/")
|
||||
switch description.first {
|
||||
case "/":
|
||||
break
|
||||
case "~":
|
||||
if description == "~" {
|
||||
self = Path.home
|
||||
return
|
||||
}
|
||||
let tilded: String
|
||||
if description.hasPrefix("~/") {
|
||||
tilded = Path.home.string
|
||||
} else {
|
||||
let username = String(pathComponents[0].dropFirst())
|
||||
|
||||
if #available(OSX 10.12, *) {
|
||||
guard let url = FileManager.default.homeDirectory(forUser: username) else { return nil }
|
||||
assert(url.scheme == "file")
|
||||
tilded = url.path
|
||||
} else {
|
||||
guard let dir = NSHomeDirectoryForUser(username) else { return nil }
|
||||
tilded = dir
|
||||
}
|
||||
}
|
||||
pathComponents.remove(at: 0)
|
||||
pathComponents.insert(contentsOf: tilded.split(separator: "/"), at: 0)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func ifExists(withPrefix prefix: String, removeFirst n: Int) {
|
||||
assert(prefix.split(separator: "/").count == n)
|
||||
|
||||
if description.hasPrefix(prefix), FileManager.default.fileExists(atPath: description) {
|
||||
pathComponents.removeFirst(n)
|
||||
}
|
||||
}
|
||||
|
||||
ifExists(withPrefix: "/private/var/automount", removeFirst: 3)
|
||||
ifExists(withPrefix: "/var/automount", removeFirst: 2)
|
||||
ifExists(withPrefix: "/private", removeFirst: 1)
|
||||
#endif
|
||||
|
||||
self.string = join_(prefix: "/", pathComponents: pathComponents)
|
||||
}
|
||||
|
||||
public init?(_ url: URL) {
|
||||
guard url.scheme == "file" else { return nil }
|
||||
self.init(string: url.path)
|
||||
//NOTE: URL cannot be a file-reference url, unlike NSURL, so this always works
|
||||
}
|
||||
|
||||
public init?(_ url: NSURL) {
|
||||
guard url.scheme == "file", let path = url.path else { return nil }
|
||||
self.init(string: path)
|
||||
// ^^ works even if the url is a file-reference url
|
||||
}
|
||||
|
||||
/// :nodoc:
|
||||
public subscript(dynamicMember pathComponent: String) -> Path {
|
||||
let str = (string as NSString).appendingPathComponent(pathComponent)
|
||||
return Path(string: str)
|
||||
public subscript(dynamicMember addendum: String) -> Path {
|
||||
//NOTE it’s possible for the string to be anything if we are invoked via
|
||||
// explicit subscript thus we use our fully sanitized `join` function
|
||||
return Path(string: join_(prefix: string, appending: addendum))
|
||||
}
|
||||
|
||||
//MARK: Properties
|
||||
@@ -57,6 +130,21 @@ public struct Path: Equatable, Hashable, Comparable {
|
||||
return URL(fileURLWithPath: string)
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a file-reference URL.
|
||||
- Note: Only NSURL can be a file-reference-URL, hence we return NSURL.
|
||||
- SeeAlso: https://developer.apple.com/documentation/foundation/nsurl/1408631-filereferenceurl
|
||||
- Important: On Linux returns an file scheme NSURL for this path string.
|
||||
*/
|
||||
public var fileReferenceURL: NSURL? {
|
||||
#if !os(Linux)
|
||||
// https://bugs.swift.org/browse/SR-2728
|
||||
return (url as NSURL).perform(#selector(NSURL.fileReferenceURL))?.takeUnretainedValue() as? NSURL
|
||||
#else
|
||||
return NSURL(fileURLWithPath: string)
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
Returns the parent directory for this path.
|
||||
|
||||
@@ -66,20 +154,37 @@ public struct Path: Equatable, Hashable, Comparable {
|
||||
- Note: always returns a valid path, `Path.root.parent` *is* `Path.root`.
|
||||
*/
|
||||
public var parent: Path {
|
||||
return Path(string: (string as NSString).deletingLastPathComponent)
|
||||
let index = string.lastIndex(of: "/")!
|
||||
let substr = string[string.indices.startIndex..<index]
|
||||
return Path(string: String(substr))
|
||||
}
|
||||
|
||||
/**
|
||||
Returns the filename extension of this path.
|
||||
- Remark: Implemented via `NSString.pathExtension`.
|
||||
- Remark: If there is no extension returns "".
|
||||
- Remark: If the filename ends with any number of ".", returns "".
|
||||
- Note: We special case eg. `foo.tar.gz`.
|
||||
*/
|
||||
@inlinable
|
||||
public var `extension`: String {
|
||||
if string.hasSuffix(".tar.gz") {
|
||||
//FIXME efficiency
|
||||
switch true {
|
||||
case string.hasSuffix(".tar.gz"):
|
||||
return "tar.gz"
|
||||
} else {
|
||||
return (string as NSString).pathExtension
|
||||
case string.hasSuffix(".tar.bz"):
|
||||
return "tar.bz"
|
||||
case string.hasSuffix(".tar.bz2"):
|
||||
return "tar.bz2"
|
||||
case string.hasSuffix(".tar.xz"):
|
||||
return "tar.xz"
|
||||
default:
|
||||
let slash = string.lastIndex(of: "/")!
|
||||
if let dot = string.lastIndex(of: "."), slash < dot {
|
||||
let foo = string.index(after: dot)
|
||||
return String(string[foo...])
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,14 +198,15 @@ public struct Path: Equatable, Hashable, Comparable {
|
||||
Path.root.join("a").join("b") // => /a/b
|
||||
Path.root.join("a").join("/b") // => /a/b
|
||||
|
||||
- Note: `..` and `.` components are interpreted.
|
||||
- Note: pathComponent *may* be multiple components.
|
||||
- Note: symlinks are *not* resolved.
|
||||
- Parameter pathComponent: The string to join with this path.
|
||||
- Returns: A new joined path.
|
||||
- SeeAlso: `Path./(_:_:)`
|
||||
*/
|
||||
public func join<S>(_ pathComponent: S) -> Path where S: StringProtocol {
|
||||
//TODO standardizingPath does more than we want really (eg tilde expansion)
|
||||
let str = (string as NSString).appendingPathComponent(String(pathComponent))
|
||||
return Path(string: (str as NSString).standardizingPath)
|
||||
public func join<S>(_ addendum: S) -> Path where S: StringProtocol {
|
||||
return Path(string: join_(prefix: string, appending: addendum))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +217,9 @@ public struct Path: Equatable, Hashable, Comparable {
|
||||
Path.root/"a"/"b" // => /a/b
|
||||
Path.root/"a"/"/b" // => /a/b
|
||||
|
||||
- Note: `..` and `.` components are interpreted.
|
||||
- Note: pathComponent *may* be multiple components.
|
||||
- Note: symlinks are *not* resolved.
|
||||
- Parameter lhs: The base path to join with `rhs`.
|
||||
- Parameter rhs: The string to join with this `lhs`.
|
||||
- Returns: A new joined path.
|
||||
@@ -168,17 +277,71 @@ public struct Path: Equatable, Hashable, Comparable {
|
||||
- Parameter dropExtension: If `true` returns the basename without its file extension.
|
||||
*/
|
||||
public func basename(dropExtension: Bool = false) -> String {
|
||||
let str = string as NSString
|
||||
if !dropExtension {
|
||||
return str.lastPathComponent
|
||||
} else {
|
||||
let ext = str.pathExtension
|
||||
if !ext.isEmpty {
|
||||
return String(str.lastPathComponent.dropLast(ext.count + 1))
|
||||
var lastPathComponent: Substring {
|
||||
let slash = string.lastIndex(of: "/")!
|
||||
let index = string.index(after: slash)
|
||||
return string[index...]
|
||||
}
|
||||
var go: Substring {
|
||||
if !dropExtension {
|
||||
return lastPathComponent
|
||||
} else {
|
||||
return str.lastPathComponent
|
||||
let ext = self.extension
|
||||
if !ext.isEmpty {
|
||||
return lastPathComponent.dropLast(ext.count + 1)
|
||||
} else {
|
||||
return lastPathComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
return String(go)
|
||||
}
|
||||
|
||||
/**
|
||||
If the path represents an actual entry that is a symlink, returns the symlink’s
|
||||
absolute destination.
|
||||
|
||||
- Important: This is not exhaustive, the resulting path may still contain
|
||||
symlink.
|
||||
- Important: The path will only be different if the last path component is a
|
||||
symlink, any symlinks in prior components are not resolved.
|
||||
- Note: If file exists but isn’t a symlink, returns `self`.
|
||||
- Note: If symlink destination does not exist, is **not** an error.
|
||||
*/
|
||||
public func readlink() throws -> Path {
|
||||
do {
|
||||
let rv = try FileManager.default.destinationOfSymbolicLink(atPath: string)
|
||||
return Path(rv) ?? parent/rv
|
||||
} catch CocoaError.fileReadUnknown {
|
||||
// file is not symlink, return `self`
|
||||
assert(exists)
|
||||
return self
|
||||
} catch {
|
||||
#if os(Linux)
|
||||
// ugh: Swift on Linux
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSCocoaErrorDomain, nsError.code == CocoaError.fileReadUnknown.rawValue, exists {
|
||||
return self
|
||||
}
|
||||
#endif
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively resolves symlinks in this path.
|
||||
public func realpath() throws -> Path {
|
||||
guard let rv = _realpath(string, nil) else { throw CocoaError.error(.fileNoSuchFile) }
|
||||
defer { free(rv) }
|
||||
guard let rvv = String(validatingUTF8: rv) else { throw CocoaError.error(.fileReadUnknownStringEncoding) }
|
||||
|
||||
// “Removing an initial component of “/private/var/automount”, “/var/automount”,
|
||||
// or “/private” from the path, if the result still indicates an existing file or
|
||||
// directory (checked by consulting the file system).”
|
||||
// ^^ we do this to not conflict with the results that other Apple APIs give
|
||||
// which is necessary if we are to have equality checks work reliably
|
||||
let rvvv = (rvv as NSString).standardizingPath
|
||||
|
||||
return Path(string: rvvv)
|
||||
}
|
||||
|
||||
/// Returns the locale-aware sort order for the two paths.
|
||||
@@ -188,3 +351,38 @@ public struct Path: Equatable, Hashable, Comparable {
|
||||
return lhs.string.compare(rhs.string, locale: .current) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func join_<S>(prefix: String, appending: S) -> String where S: StringProtocol {
|
||||
return join_(prefix: prefix, pathComponents: appending.split(separator: "/"))
|
||||
}
|
||||
|
||||
private func join_<S>(prefix: String, pathComponents: S) -> String where S: Sequence, S.Element: StringProtocol {
|
||||
assert(prefix.first == "/")
|
||||
|
||||
var rv = prefix
|
||||
for component in pathComponents {
|
||||
|
||||
assert(!component.contains("/"))
|
||||
|
||||
switch component {
|
||||
case "..":
|
||||
let start = rv.indices.startIndex
|
||||
let index = rv.lastIndex(of: "/")!
|
||||
if start == index {
|
||||
rv = "/"
|
||||
} else {
|
||||
rv = String(rv[start..<index])
|
||||
}
|
||||
case ".":
|
||||
break
|
||||
default:
|
||||
if rv == "/" {
|
||||
rv = "/\(component)"
|
||||
} else {
|
||||
rv = "\(rv)/\(component)"
|
||||
}
|
||||
}
|
||||
}
|
||||
return rv
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user