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:
Max Howell
2019-02-11 12:42:50 -05:00
parent 6c84754ad8
commit 709c3fb99d
7 changed files with 515 additions and 49 deletions

View File

@@ -211,6 +211,32 @@ public extension Path {
try FileManager.default.moveItem(atPath: string, toPath: newpath.string)
return newpath
}
/**
Creates a symlink of this file at `as`.
- Note: If `self` does not exist, is **not** an error.
*/
@discardableResult
func symlink(as: Path) throws -> Path {
try FileManager.default.createSymbolicLink(atPath: `as`.string, withDestinationPath: string)
return `as`
}
/**
Creates a symlink of this file with the same filename in the `into` directory.
- Note: If into does not exist, creates the directory with intermediate directories if necessary.
*/
@discardableResult
func symlink(into dir: Path) throws -> Path {
if !dir.exists {
try dir.mkdir(.p)
} else if !dir.isDirectory {
throw CocoaError.error(.fileWriteFileExists)
}
let dst = dir/basename()
try FileManager.default.createSymbolicLink(atPath: dst.string, withDestinationPath: string)
return dst
}
}
/// Options for `Path.mkdir(_:)`

View File

@@ -1,5 +1,3 @@
import class Foundation.NSString
extension Path: CustomStringConvertible {
/// Returns `Path.string`
public var description: String {

View File

@@ -2,7 +2,7 @@ import Foundation
#if os(Linux)
import func Glibc.access
#else
import func Darwin.access
import Darwin
#endif
public extension Path {
@@ -55,4 +55,11 @@ public extension Path {
return false
}
}
/// Returns `true` if the file is a symbolic-link (symlink).
var isSymlink: Bool {
var sbuf = stat()
lstat(string, &sbuf)
return (sbuf.st_mode & S_IFMT) == S_IFLNK
}
}

View File

@@ -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 its 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 symlinks
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 isnt 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
}